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 01/50] 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 02/50] 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 03/50] 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 04/50] 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 05/50] 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 06/50] 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 07/50] 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 08/50] 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 09/50] 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 10/50] 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 11/50] 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 12/50] 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 13/50] 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 14/50] 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 15/50] 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 16/50] 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 17/50] 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 18/50] 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 19/50] 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 20/50] 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 21/50] 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 22/50] 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 23/50] 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 24/50] 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 25/50] 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 26/50] 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 27/50] 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 28/50] 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 29/50] 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 30/50] 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 31/50] 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 32/50] 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 33/50] 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 34/50] 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 35/50] 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 36/50] 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 37/50] 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 38/50] 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 39/50] 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 40/50] 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 41/50] 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 42/50] 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 43/50] 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 44/50] 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 45/50] 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 46/50] 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 47/50] 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 48/50] 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 49/50] 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 50/50] 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"},