diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..06dfdb664 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,63 @@ +# Security Policy + +Slack takes the security of its software and services seriously, including all open-source repositories managed through the [slackapi](https://github.com/slackapi) GitHub organization. + +## Reporting a Vulnerability + +**Do NOT report security vulnerabilities through public GitHub issues, pull requests, or discussions.** + +If you believe you have found a security vulnerability in `slack-bolt`, please report it through the Slack bug bounty program on HackerOne: + +**** + +Even if `slack-bolt` is not explicitly listed as an in-scope asset on the HackerOne program page, reports for vulnerabilities in this package should still be submitted there. The Slack security team triages reports for all `slackapi` open-source repositories through this program. + +If HackerOne is inaccessible, you may alternatively report the issue to [security@salesforce.com](mailto:security@salesforce.com). + +Please do not discuss potential vulnerabilities in public without first coordinating with the security team. + +## What to Include + +To help us triage and respond quickly, please include: + +- Type of vulnerability (e.g., signature bypass, token leakage, denial of service) +- Affected version(s) of `slack-bolt` +- Step-by-step reproduction instructions +- Proof-of-concept code or payloads, if available +- Impact assessment: what an attacker could achieve +- Any specific configuration required to trigger the vulnerability +- Affected source file paths, if known + +## Threat Model + +Bolt for Python is a framework that sits between the Slack platform and developer application code. Its security boundary covers the integrity and confidentiality of that interface. + +### In Scope + +The following are considered framework vulnerabilities: + +- Bypass of request signature verification (HMAC-SHA256 validation) +- OAuth token leakage or cross-tenant token exposure during authorization flows +- Denial of service caused by malformed or specially crafted payloads processed by framework internals +- Authentication or authorization bypass in any built-in adapter +- Information disclosure through framework error responses or timing side channels +- Bypass of the `ssl_check` endpoint protections + +### Out of Scope + +The following are NOT framework vulnerabilities: + +- Vulnerabilities in the Python runtime, operating system, or hosting infrastructure +- Security issues in developer application logic built on top of Bolt (e.g., SQL injection caused by passing unsanitized payload data to a database) +- Vulnerabilities in third-party PyPI packages chosen and installed by the developer outside of Bolt's direct dependencies +- Vulnerabilities in Slack's server-side platform infrastructure (report those directly under Slack's main HackerOne scope) +- Attacks that require possession of a valid signing secret or bot token +- Arbitrary attribute injection or unsafe deserialization caused by developer code handling untrusted input +- Issues that only affect end-of-life versions with no reproduction on supported versions + +## Disclosure Policy + +This project follows coordinated disclosure: + +- Allow a reasonable timeframe for the team to investigate, develop, and release a fix before any public disclosure. +- Researchers who follow responsible disclosure practices are eligible for recognition and bounty consideration through the Slack HackerOne program. diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index f8edeeabd..47d9ccd58 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -71,8 +71,8 @@ If you make changes to `slack_bolt/adapter/*`, please verify if it surely works ```sh # Install all optional dependencies -$ pip install -r requirements/adapter.txt -$ pip install -r requirements/adapter_testing.txt +$ pip install -r requirements/adapter_dev.txt +$ pip install -r requirements/test_adapter.txt # Set required env variables $ export SLACK_SIGNING_SECRET=*** diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b158c72ea..013f0e216 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} @@ -37,7 +37,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} @@ -48,13 +48,13 @@ jobs: run: | pip install -U pip pip install -U . - pip install -r requirements/tools.txt + pip install -r requirements/dev_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 + pip install -r requirements/async_dev.txt + pip install -r requirements/adapter_dev.txt - name: Type check all modules run: mypy --config-file pyproject.toml @@ -77,7 +77,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} @@ -88,15 +88,15 @@ jobs: run: | pip install -U pip pip install . - pip install -r requirements/testing_without_asyncio.txt + pip install -r requirements/test.txt - name: Run tests without aiohttp run: | pytest tests/slack_bolt/ --junitxml=reports/test_slack_bolt.xml pytest tests/scenario_tests/ --junitxml=reports/test_scenario.xml - name: Install adapter dependencies run: | - pip install -r requirements/adapter.txt - pip install -r requirements/adapter_testing.txt + pip install -r requirements/adapter_dev.txt + pip install -r requirements/test_adapter.txt - name: Run tests for HTTP Mode adapters run: | pytest tests/adapter_tests/ \ @@ -105,14 +105,14 @@ jobs: --junitxml=reports/test_adapter.xml - name: Install async dependencies run: | - pip install -r requirements/async.txt + pip install -r requirements/async_dev.txt - name: Run tests for Socket Mode adapters run: | # Requires async test dependencies pytest tests/adapter_tests/socket_mode/ --junitxml=reports/test_adapter_socket_mode.xml - name: Install all dependencies run: | - pip install -r requirements/testing.txt + pip install -r requirements/test_async.txt - name: Run tests for HTTP Mode adapters (ASGI) run: | # Requires async test dependencies @@ -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@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: directory: ./reports/ fail_ci_if_error: true @@ -144,7 +144,7 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} @@ -155,14 +155,14 @@ jobs: 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 + pip install -r requirements/adapter_dev.txt + pip install -r requirements/test_async.txt + pip install -r requirements/test_adapter.txt - name: Run all tests for codecov run: | pytest --cov=./slack_bolt/ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: fail_ci_if_error: true report_type: coverage diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 964eb2c77..56493454d 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,7 +18,7 @@ jobs: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.release.tag_name || github.ref }} persist-credentials: false diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index c29bface2..9d99c40da 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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: days-before-issue-stale: 30 days-before-issue-close: 10 diff --git a/AGENTS.md b/AGENTS.md index 892a858e7..005f6eeda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,7 +183,7 @@ Then wire it into `BoltContext` (`slack_bolt/context/context.py`) and `AsyncBolt 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 +4. Add the framework to `requirements/adapter_dev.txt` with version constraints 5. Add adapter tests in `tests/adapter_tests/` (sync) or `tests/adapter_tests_async/` (async) ### Adding a Kwargs-Injectable Argument @@ -205,12 +205,12 @@ The core package has a **single required runtime dependency**: `slack_sdk` (defi **`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`) +- `async_dev.txt` -- async runtime deps (`aiohttp`, `websockets`) +- `adapter_dev.txt` -- all framework adapter deps (Flask, Django, FastAPI, etc.) +- `test_async.txt` -- test runner deps (`pytest`, `pytest-asyncio`, includes `async_dev.txt`) +- `test.txt` -- test deps without async (`pytest`, `pytest-cov`) +- `test_adapter.txt` -- adapter-specific test deps (`moto`, `boddle`, `sanic-testing`) +- `dev_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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..b69c021ed --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +## Security + +Please report any security issue to [https://www.sfdc.co/SubmitVuln](https://www.sfdc.co/SubmitVuln) +as soon as it is discovered. This library limits its runtime dependencies in +order to reduce the total cost of ownership as much as can be, but all consumers +should remain vigilant and have their security stakeholders review all third-party +products (3PP) like this one and their dependencies. diff --git a/docs/english/concepts/adding-agent-features.md b/docs/english/concepts/adding-agent-features.md index cbd164630..87a8d31cb 100644 --- a/docs/english/concepts/adding-agent-features.md +++ b/docs/english/concepts/adding-agent-features.md @@ -190,7 +190,7 @@ def handle_message( # 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( + client.reactions_add( channel=channel_id, timestamp=event["ts"], name="eyes", @@ -274,7 +274,7 @@ The `say_stream` utility streamlines calling the Python Slack SDK's [`WebClient. | `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`. +If either `channel_id` or `thread_ts` cannot be sourced, the utility will be `None`. ```python streamer = say_stream() @@ -571,7 +571,7 @@ def handle_app_mentioned( except Exception as e: logger.exception(f"Failed to handle app mention: {e}") - await say( + say( text=f":warning: Something went wrong! ({e})", thread_ts=event.get("thread_ts") or event["ts"], ) diff --git a/docs/english/concepts/message-sending.md b/docs/english/concepts/message-sending.md index 090503ff2..c4d1b0467 100644 --- a/docs/english/concepts/message-sending.md +++ b/docs/english/concepts/message-sending.md @@ -54,7 +54,7 @@ The `say_stream` utility streamlines calling the Python Slack SDK's [`WebClient. | `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`. +If either `channel_id` or `thread_ts` cannot be sourced, 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. @@ -79,7 +79,7 @@ def handle_app_mention(client: WebClient, say_stream: SayStream): def handle_message(client: WebClient, say_stream: SayStream): stream = say_stream() - stream.append(markdown_text="Let me consult my *vast knowledge database*...) + stream.append(markdown_text="Let me consult my *vast knowledge database*...") stream.stop() if __name__ == "__main__": diff --git a/docs/english/concepts/updating-pushing-views.md b/docs/english/concepts/updating-pushing-views.md index 8c05e79c8..ce285c814 100644 --- a/docs/english/concepts/updating-pushing-views.md +++ b/docs/english/concepts/updating-pushing-views.md @@ -1,6 +1,6 @@ # Updating & pushing views -Modals contain a stack of views. When you call [`views_open`](https://api./reference/methods/views.open/slack.com/methods/views.open), you add the root view to the modal. After the initial call, you can dynamically update a view by calling [`views_update`](/reference/methods/views.update/), or stack a new view on top of the root view by calling [`views_push`](/reference/methods/views.push/) +Modals contain a stack of views. When you call [`views_open`](/reference/methods/views.open/), you add the root view to the modal. After the initial call, you can dynamically update a view by calling [`views_update`](/reference/methods/views.update/), or stack a new view on top of the root view by calling [`views_push`](/reference/methods/views.push/) ## The `views_update` method diff --git a/docs/english/concepts/using-the-assistant-class.md b/docs/english/concepts/using-the-assistant-class.md index ed004dc35..40c97d0cd 100644 --- a/docs/english/concepts/using-the-assistant-class.md +++ b/docs/english/concepts/using-the-assistant-class.md @@ -51,7 +51,7 @@ You _could_ go it alone and [listen](/tools/bolt-python/concepts/event-listening While the `assistant_thread_started` and `assistant_thread_context_changed` events do provide Slack-client thread context information, the `message.im` event does not. Any subsequent user message events won't contain thread context data. For that reason, Bolt not only provides a way to store thread context — the `threadContextStore` property — but it also provides a `DefaultThreadContextStore` instance that is utilized by default. This implementation relies on storing and retrieving [message metadata](/messaging/message-metadata/) as the user interacts with the app. -If you do provide your own `threadContextStore` property, it must feature `get` and `save` methods. +If you do provide your own `threadContextStore` property, it must feature `find` and `save` methods. :::tip[Refer to the [reference docs](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] ::: @@ -138,10 +138,10 @@ Messages sent to the app do not contain a [subtype](/reference/events/message#su There are three utilities that are particularly useful in curating the user experience: * [`say`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.Say) -* [`setTitle`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetTitle) -* [`setStatus`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetStatus) +* [`set_title`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetTitle) +* [`set_status`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetStatus) -Within the `setStatus` utility, you can cycle through strings passed into a `loading_messages` array. +Within the `set_status` utility, you can cycle through strings passed into a `loading_messages` list. ```python # This listener is invoked when the human user sends a reply in the assistant thread diff --git a/docs/english/concepts/view-submissions.md b/docs/english/concepts/view-submissions.md index 4ff4c2da7..b961e0376 100644 --- a/docs/english/concepts/view-submissions.md +++ b/docs/english/concepts/view-submissions.md @@ -90,6 +90,6 @@ def handle_submission(ack, body, client, view, logger): # Message the user try: client.chat_postMessage(channel=user, text=msg) - except e: + except Exception as e: logger.exception(f"Failed to post a message {e}") ``` diff --git a/docs/english/experiments.md b/docs/english/experiments.md index 13adf0a32..443a334be 100644 --- a/docs/english/experiments.md +++ b/docs/english/experiments.md @@ -1,30 +1,11 @@ # 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." +Bolt for Python occasionally 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() -``` +There are currently no active experiments. We're steadily staying stable. diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md index c3c5e2af7..9a5e3ee50 100644 --- a/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md +++ b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md @@ -14,7 +14,7 @@ In this tutorial we will: ## Prerequisites {#prereqs} -The custom steps feature is compatible with Bolt version 1.20.0 and above. First, update your `package.json` file to reflect version 1.20.0 of Bolt, then run the following command in your terminal: +The custom steps feature is compatible with Bolt version 1.20.0 and above. First, update your `requirements.txt` file to reflect version 1.20.0 of Bolt (e.g., `slack-bolt>=1.20.0`), then run the following commands in your terminal: ```sh python3 -m venv .venv @@ -215,9 +215,8 @@ def manager_resp_handler(ack: Ack, action, body: dict, client: WebClient, comple client.chat_update( channel=body['channel']['id'], - message=body['message'], ts=body["message"]["ts"], - text=f'Request {"approved" if request_decision == 'approve' else "denied"}!' + text=f"Request {'approved' if request_decision == 'approve' else 'denied'}!" ) complete({ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md b/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md index 1dceed45a..75be7aa32 100644 --- a/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md +++ b/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md @@ -47,7 +47,9 @@ You can also open a terminal window from inside VSCode like this: `Ctrl` + `~` Once in VSCode, open the terminal. Let's install our package dependencies: run the following command(s) in the terminal inside VSCode: ```sh -npm install +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt ``` We now have a Bolt app ready for development! Open the `manifest.json` file and copy its contents; you'll need this in the next step. @@ -99,7 +101,7 @@ You will then have a bot token. Again, copy that value and save it somewhere acc ## Starting your local development server {#local} -While building your app, you can see your changes appear in your workspace in real-time with `npm start`. Soon we'll start our local development server and see what our sample code is all about! But first, we need to store those tokens we gathered as environment variables. +While building your app, you can see your changes appear in your workspace in real-time with `python app.py`. Soon we'll start our local development server and see what our sample code is all about! But first, we need to store those tokens we gathered as environment variables. Navigate back to VSCode. Rename the `.env.sample` file to `.env`. Open this file and update `SLACK_APP_TOKEN` and `SLACK_BOT_TOKEN` with the values you previously saved. It will look like this, with your actual token values where you see `` and ``: @@ -111,7 +113,7 @@ SLACK_BOT_TOKEN= Now save the file and try starting your app: ```sh -npm start +python app.py ``` You'll know the local development server is up and running successfully when it emits a bunch of `[DEBUG]` statements to your terminal, the last one containing `connected:ready`. diff --git a/docs/english/tutorial/custom-steps.md b/docs/english/tutorial/custom-steps.md index 66dc16198..50bd723ce 100644 --- a/docs/english/tutorial/custom-steps.md +++ b/docs/english/tutorial/custom-steps.md @@ -111,9 +111,9 @@ Here is a sample app manifest laying out a step definition. This definition tell "name": "user_id" } }, - "required": { + "required": [ "user_id" - } + ] }, "output_parameters": { "properties": { @@ -124,9 +124,9 @@ Here is a sample app manifest laying out a step definition. This definition tell "name": "user_id" } }, - "required": { + "required": [ "user_id" - } + ] }, } } @@ -157,7 +157,7 @@ Notice in the example code here that the name of the step, `sample_step`, is the ```py @app.function("sample_step") -def handle_sample_step_event(inputs: dict, fail: Fail, complete: Complete,logger: logging.Logger): +def handle_sample_step_event(client: WebClient, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger): user_id = inputs["user_id"] try: client.chat_postMessage( @@ -226,7 +226,7 @@ The second argument is the callback function, or the logic that will run when yo Field | Description ------|------------ `client` | A `WebClient` instance used to make things happen in Slack. From sending messages to opening modals, `client` makes it all happen. For a full list of available methods, refer to the [Web API methods](/reference/methods). Read more about the `WebClient` for Bolt Python [here](https://docs.slack.dev/tools/bolt-python/concepts/web-api/). -`complete` | A utility method that invokes `functions.completeSuccess`. This method indicates to Slack that a step has completed successfully without issue. When called, `complete` requires you include an `outputs` object that matches your step definition in [`output_parameters`](#inputs-outputs). +`complete` | A utility method that invokes `functions.completeSuccess`. This method indicates to Slack that a step has completed successfully without issue. When called, `complete` accepts an optional `outputs` object that matches your step definition in [`output_parameters`](#inputs-outputs). `fail` | A utility method that invokes `functions.completeError`. True to its name, this method signals to Slack that a step has failed to complete. The `fail` method requires an argument of `error` to be sent along with it, which is used to help users understand what went wrong. `inputs` | An alias for the `input_parameters` that were provided to the step upon execution. diff --git a/pyproject.toml b/pyproject.toml index 88842d0d9..ac197c4f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = ["slack_sdk>=3.38.0,<4"] [project.urls] Documentation = "https://docs.slack.dev/tools/bolt-python/" +Source = "https://github.com/slackapi/bolt-python" [tool.setuptools.packages.find] include = ["slack_bolt*"] diff --git a/requirements/adapter.txt b/requirements/adapter_dev.txt similarity index 53% rename from requirements/adapter.txt rename to requirements/adapter_dev.txt index 2564aae79..14dfa9c84 100644 --- a/requirements/adapter.txt +++ b/requirements/adapter_dev.txt @@ -1,5 +1,5 @@ -# pip install -r requirements/adapter.txt -# NOTE: any of async ones requires pip install -r requirements/async.txt too +# pip install -r requirements/adapter_dev.txt +# NOTE: any of async ones requires pip install -r requirements/async_dev.txt too # used only under slack_bolt/adapter boto3<=2 bottle>=0.12,<1 @@ -7,11 +7,14 @@ chalice>=1.28,<1.31; python_version<"3.9" chalice>=1.32.0,<2; python_version>="3.9" cheroot<12 CherryPy>=18,<19 -Django>=3,<6 +Django>=3.2,<4; python_version<"3.8" +Django>=4.2.30,<6; python_version>="3.8" falcon>=2,<4; python_version<"3.9" falcon>=4.2.0,<5; python_version>="3.9" -fastapi>=0.70.0,<1 -Flask>=1,<4 +fastapi>=0.70.0,<1; python_version<"3.9" +fastapi>=0.128.8,<1; python_version>="3.9" +Flask>=1,<4; python_version<"3.9" +Flask>=3.1.3,<4; python_version>="3.9" Werkzeug>=2,<3; python_version<"3.9" Werkzeug>=3.1.8,<4; python_version>="3.9" pyramid>=1,<3 @@ -21,10 +24,11 @@ setuptools<82 # Pinned: Pyramid depends on pkg_resources (deprecated in setupto # Note: Sanic imports tracerite with wild card versions tracerite<1.1.2; python_version<="3.8" # older versions of python are not compatible with tracerite>1.1.2 sanic>=21,<24; python_version<="3.8" -sanic>=21,<26; python_version>"3.8" +sanic>=25.3.0,<26; python_version>"3.8" -starlette>=0.19.1,<1 -tornado>=6,<7 -uvicorn<1 # The oldest version can vary among Python runtime versions -gunicorn>=20,<24 -websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation +starlette>=0.19.1,<0.45; python_version<"3.9" +starlette>=0.49.3,<1; python_version>="3.9" +tornado>=6.2,<7; python_version<"3.9" +tornado>=6.5.6,<7; python_version>="3.9" +websocket_client>=1.2.3,<1.9; python_version<"3.9" # Socket Mode 3rd party implementation +websocket_client>=1.9.0,<2; python_version>="3.9" # Socket Mode 3rd party implementation diff --git a/requirements/async.txt b/requirements/async.txt deleted file mode 100644 index af3e49913..000000000 --- a/requirements/async.txt +++ /dev/null @@ -1,3 +0,0 @@ -# pip install -r requirements/async.txt -aiohttp>=3,<4 -websockets<16 diff --git a/requirements/async_dev.txt b/requirements/async_dev.txt new file mode 100644 index 000000000..606d10fef --- /dev/null +++ b/requirements/async_dev.txt @@ -0,0 +1,4 @@ +# pip install -r requirements/async_dev.txt +aiohttp>=3,<4; python_version<"3.9" +aiohttp>=3.13.5,<4; python_version>="3.9" +websockets<16 diff --git a/requirements/tools.txt b/requirements/dev_tools.txt similarity index 100% rename from requirements/tools.txt rename to requirements/dev_tools.txt diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 000000000..e007e6637 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,3 @@ +# pip install -r requirements/test.txt +pytest<8.5 +pytest-cov>=7.1.0,<8; python_version>="3.14" # only needed to evaluate coverage on the latest supported python version diff --git a/requirements/adapter_testing.txt b/requirements/test_adapter.txt similarity index 57% rename from requirements/adapter_testing.txt rename to requirements/test_adapter.txt index dd4a1cf84..ecb3741f2 100644 --- a/requirements/adapter_testing.txt +++ b/requirements/test_adapter.txt @@ -1,5 +1,4 @@ -# pip install -r requirements/adapter_testing.txt +# pip install -r requirements/test_adapter.txt moto>=3,<6 # For AWS tests -docker>=5,<8 # Used by moto boddle>=0.2.9,<0.3 # For Bottle app tests sanic-testing>=0.7 diff --git a/requirements/test_async.txt b/requirements/test_async.txt new file mode 100644 index 000000000..8fa6c6806 --- /dev/null +++ b/requirements/test_async.txt @@ -0,0 +1,6 @@ +# pip install -r requirements/test_async.txt +-r test.txt +-r async_dev.txt +asgiref>=3.7.2,<3.8; python_version<"3.9" +asgiref>=3.11.1,<4; python_version>="3.9" +pytest-asyncio<2; diff --git a/requirements/testing.txt b/requirements/testing.txt deleted file mode 100644 index 62fdcca2d..000000000 --- a/requirements/testing.txt +++ /dev/null @@ -1,4 +0,0 @@ -# pip install -r requirements/testing.txt --r testing_without_asyncio.txt --r async.txt -pytest-asyncio<2; diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt deleted file mode 100644 index 441b49f8b..000000000 --- a/requirements/testing_without_asyncio.txt +++ /dev/null @@ -1,3 +0,0 @@ -# pip install -r requirements/testing_without_asyncio.txt -pytest<8.5 -pytest-cov>=3,<8 diff --git a/scripts/format.sh b/scripts/format.sh index e73bcdac4..771cbb413 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -7,7 +7,7 @@ cd ${script_dir}/.. if [[ "$1" != "--no-install" ]]; then export PIP_REQUIRE_VIRTUALENV=1 pip install -U pip - pip install -U -r requirements/tools.txt + pip install -U -r requirements/dev_tools.txt fi black slack_bolt/ tests/ diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index c3b9fd260..275aa0fe1 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -6,8 +6,8 @@ script_dir=$(dirname "$0") cd "${script_dir}/.." pip install -U pip -pip install -U -r requirements/adapter.txt -pip install -U -r requirements/async.txt +pip install -U -r requirements/adapter_dev.txt +pip install -U -r requirements/async_dev.txt pip install -U pdoc3 pip install . rm -rf docs/reference diff --git a/scripts/install.sh b/scripts/install.sh index 96159c63c..64cdf1561 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -13,10 +13,10 @@ pip install -U pip 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 +pip install -U -r requirements/test_async.txt +pip install -U -r requirements/adapter_dev.txt +pip install -U -r requirements/test_adapter.txt +pip install -U -r requirements/dev_tools.txt # To avoid errors due to the old versions of click forced by Chalice pip install -U pip click diff --git a/scripts/lint.sh b/scripts/lint.sh index efee01ebc..3a3037419 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -6,7 +6,7 @@ cd ${script_dir}/.. if [[ "$1" != "--no-install" ]]; then pip install -U pip - pip install -U -r requirements/tools.txt + pip install -U -r requirements/dev_tools.txt fi flake8 slack_bolt/ && flake8 examples/ diff --git a/scripts/run_mypy.sh b/scripts/run_mypy.sh index 27589b348..c9234f87a 100755 --- a/scripts/run_mypy.sh +++ b/scripts/run_mypy.sh @@ -7,9 +7,9 @@ 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 + pip install -U -r requirements/async_dev.txt + pip install -U -r requirements/adapter_dev.txt + pip install -U -r requirements/dev_tools.txt fi mypy --config-file pyproject.toml diff --git a/slack_bolt/adapter/asgi/base_handler.py b/slack_bolt/adapter/asgi/base_handler.py index 5e68c51f4..adfa060c1 100644 --- a/slack_bolt/adapter/asgi/base_handler.py +++ b/slack_bolt/adapter/asgi/base_handler.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Union +from typing import Callable, Union from .http_request import AsgiHttpRequest from .http_response import AsgiHttpResponse @@ -47,15 +47,13 @@ async def _get_http_response(self, method: str, path: str, request: AsgiHttpRequ return AsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body) return AsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found") - async def _handle_lifespan(self, receive: Callable) -> Dict[str, str]: - while True: - lifespan = await receive() - if lifespan["type"] == "lifespan.startup": - """Do something before startup""" - return {"type": "lifespan.startup.complete"} - if lifespan["type"] == "lifespan.shutdown": - """Do something before shutdown""" - return {"type": "lifespan.shutdown.complete"} + async def _handle_lifespan(self, receive: Callable, send: Callable) -> None: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + message = await receive() + if message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) async def __call__(self, scope: scope_type, receive: Callable, send: Callable) -> None: if scope["type"] == "http": @@ -66,6 +64,6 @@ async def __call__(self, scope: scope_type, receive: Callable, send: Callable) - await send(response.get_response_body()) return if scope["type"] == "lifespan": - await send(await self._handle_lifespan(receive)) + await self._handle_lifespan(receive, send) return raise TypeError(f"Unsupported scope type: {scope['type']!r}") diff --git a/slack_bolt/adapter/asgi/http_response.py b/slack_bolt/adapter/asgi/http_response.py index c8178b8f5..58969f137 100644 --- a/slack_bolt/adapter/asgi/http_response.py +++ b/slack_bolt/adapter/asgi/http_response.py @@ -8,11 +8,14 @@ class AsgiHttpResponse: def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): self.status: int = status - self.raw_headers: List[Tuple[bytes, bytes]] = [ - (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items() - ] - self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING))) self.body: bytes = bytes(body, ENCODING) + self.raw_headers: List[Tuple[bytes, bytes]] = [] + for key, values in headers.items(): + if key.lower() == "content-length": + continue + for v in values: + self.raw_headers.append((bytes(key, ENCODING), bytes(v, ENCODING))) + self.raw_headers.append((b"content-length", bytes(str(len(self.body)), ENCODING))) def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]: return { diff --git a/slack_bolt/adapter/falcon/async_resource.py b/slack_bolt/adapter/falcon/async_resource.py index 8d03b456c..fdb2d975f 100644 --- a/slack_bolt/adapter/falcon/async_resource.py +++ b/slack_bolt/adapter/falcon/async_resource.py @@ -1,6 +1,7 @@ from datetime import datetime from http import HTTPStatus +from falcon import MEDIA_TEXT from falcon import version as falcon_version from falcon.asgi import Request, Response from slack_bolt import BoltResponse @@ -40,9 +41,9 @@ async def on_get(self, req: Request, resp: Response): await self._write_response(bolt_resp, resp) return - resp.status = "404" - # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." + resp.status = HTTPStatus.NOT_FOUND + resp.content_type = MEDIA_TEXT + resp.text = "The page is not found..." async def on_post(self, req: Request, resp: Response): bolt_req = await self._to_bolt_request(req) diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index 53792775f..5d162ad23 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -1,6 +1,7 @@ from datetime import datetime from http import HTTPStatus +from falcon import MEDIA_TEXT from falcon import Request, Response, version as falcon_version from slack_bolt import BoltResponse @@ -34,9 +35,9 @@ def on_get(self, req: Request, resp: Response): self._write_response(bolt_resp, resp) return - resp.status = "404" - # Falcon 4.x w/ mypy fails to correctly infer the str type here - resp.body = "The page is not found..." + resp.status = HTTPStatus.NOT_FOUND + resp.content_type = MEDIA_TEXT + resp.text = "The page is not found..." def on_post(self, req: Request, resp: Response): bolt_req = self._to_bolt_request(req) diff --git a/slack_bolt/adapter/socket_mode/async_internals.py b/slack_bolt/adapter/socket_mode/async_internals.py index c2965f766..428ab437c 100644 --- a/slack_bolt/adapter/socket_mode/async_internals.py +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -8,13 +8,14 @@ from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse +from slack_bolt.adapter.socket_mode.internals import build_headers from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse async def run_async_bolt_app(app: AsyncApp, req: SocketModeRequest): - bolt_req: AsyncBoltRequest = AsyncBoltRequest(mode="socket_mode", body=req.payload) + bolt_req: AsyncBoltRequest = AsyncBoltRequest(mode="socket_mode", body=req.payload, headers=build_headers(req)) bolt_resp: BoltResponse = await app.async_dispatch(bolt_req) return bolt_resp diff --git a/slack_bolt/adapter/socket_mode/internals.py b/slack_bolt/adapter/socket_mode/internals.py index 8eb751b4d..6289f28f5 100644 --- a/slack_bolt/adapter/socket_mode/internals.py +++ b/slack_bolt/adapter/socket_mode/internals.py @@ -3,6 +3,7 @@ import json import logging from time import time +from typing import Dict, Optional, Sequence, Union from slack_sdk.socket_mode.client import BaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest @@ -13,8 +14,18 @@ from slack_bolt.response import BoltResponse +def build_headers(req: SocketModeRequest) -> Optional[Dict[str, Union[str, Sequence[str]]]]: + # Mirror the HTTP mode retry headers so middleware/listeners can detect Events API retries + headers: Dict[str, Union[str, Sequence[str]]] = {} + if req.retry_attempt is not None: + headers["x-slack-retry-num"] = str(req.retry_attempt) + if req.retry_reason is not None: + headers["x-slack-retry-reason"] = req.retry_reason + return headers or None + + def run_bolt_app(app: App, req: SocketModeRequest): - bolt_req: BoltRequest = BoltRequest(mode="socket_mode", body=req.payload) + bolt_req: BoltRequest = BoltRequest(mode="socket_mode", body=req.payload, headers=build_headers(req)) bolt_resp: BoltResponse = app.dispatch(bolt_req) return bolt_resp diff --git a/slack_bolt/adapter/wsgi/handler.py b/slack_bolt/adapter/wsgi/handler.py index 2861b4425..fef54f73e 100644 --- a/slack_bolt/adapter/wsgi/handler.py +++ b/slack_bolt/adapter/wsgi/handler.py @@ -1,6 +1,10 @@ -from typing import Any, Callable, Dict, Iterable, List, Tuple +from typing import TYPE_CHECKING, Iterable from slack_bolt import App + +if TYPE_CHECKING: + from wsgiref.types import StartResponse, WSGIEnvironment + from slack_bolt.adapter.wsgi.http_request import WsgiHttpRequest from slack_bolt.adapter.wsgi.http_response import WsgiHttpResponse from slack_bolt.request import BoltRequest @@ -69,14 +73,17 @@ def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse: def __call__( self, - environ: Dict[str, Any], - start_response: Callable[[str, List[Tuple[str, str]]], None], + environ: "WSGIEnvironment", + start_response: "StartResponse", ) -> Iterable[bytes]: request = WsgiHttpRequest(environ) - if "HTTP" in request.protocol: + if request.protocol.startswith("HTTP"): response: WsgiHttpResponse = self._get_http_response( request=request, ) - start_response(response.status, response.get_headers()) - return response.get_body() - raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}") + else: + response = WsgiHttpResponse( + status=400, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Bad Request" + ) + start_response(response.status, response.get_headers()) + return response.get_body() diff --git a/slack_bolt/adapter/wsgi/http_request.py b/slack_bolt/adapter/wsgi/http_request.py index 460d8f531..644d0e333 100644 --- a/slack_bolt/adapter/wsgi/http_request.py +++ b/slack_bolt/adapter/wsgi/http_request.py @@ -1,4 +1,7 @@ -from typing import Any, Dict, Sequence, Union +from typing import TYPE_CHECKING, Dict, Sequence, Union + +if TYPE_CHECKING: + from wsgiref.types import WSGIEnvironment from .internals import ENCODING @@ -12,7 +15,7 @@ class WsgiHttpRequest: __slots__ = ("method", "path", "query_string", "protocol", "environ") - def __init__(self, environ: Dict[str, Any]): + def __init__(self, environ: "WSGIEnvironment"): self.method: str = environ.get("REQUEST_METHOD", "GET") self.path: str = environ.get("PATH_INFO", "") self.query_string: str = environ.get("QUERY_STRING", "") @@ -33,5 +36,5 @@ def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]: def get_body(self) -> str: if "wsgi.input" not in self.environ: return "" - content_length = int(self.environ.get("CONTENT_LENGTH", 0)) + content_length = int(self.environ.get("CONTENT_LENGTH") or 0) return self.environ["wsgi.input"].read(content_length).decode(ENCODING) diff --git a/slack_bolt/adapter/wsgi/http_response.py b/slack_bolt/adapter/wsgi/http_response.py index 1ad32e672..32956d276 100644 --- a/slack_bolt/adapter/wsgi/http_response.py +++ b/slack_bolt/adapter/wsgi/http_response.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from typing import Dict, Iterable, List, Sequence, Tuple +from typing import Dict, Iterable, List, Optional, Sequence, Tuple from .internals import ENCODING @@ -13,18 +13,19 @@ class WsgiHttpResponse: __slots__ = ("status", "_headers", "_body") - def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): + def __init__(self, status: int, headers: Optional[Dict[str, Sequence[str]]] = None, body: str = ""): _status = HTTPStatus(status) self.status = f"{_status.value} {_status.phrase}" - self._headers = headers + self._headers = headers or {} self._body = bytes(body, ENCODING) def get_headers(self) -> List[Tuple[str, str]]: headers: List[Tuple[str, str]] = [] - for key, value in self._headers.items(): + for key, values in self._headers.items(): if key.lower() == "content-length": continue - headers.append((key, value[0])) + for v in values: + headers.append((key, v)) headers.append(("content-length", str(len(self._body)))) return headers diff --git a/slack_bolt/context/say_stream/async_say_stream.py b/slack_bolt/context/say_stream/async_say_stream.py index af776891b..df9b362e2 100644 --- a/slack_bolt/context/say_stream/async_say_stream.py +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -34,6 +34,9 @@ async def __call__( recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, thread_ts: Optional[str] = None, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: Optional[str] = None, **kwargs, ) -> AsyncChatStream: """Starts a new chat stream with context.""" @@ -51,6 +54,9 @@ async def __call__( 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, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, ) return await self.client.chat_stream( @@ -58,5 +64,8 @@ async def __call__( 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, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, ) diff --git a/slack_bolt/context/say_stream/say_stream.py b/slack_bolt/context/say_stream/say_stream.py index b6a5ca797..15bdcc110 100644 --- a/slack_bolt/context/say_stream/say_stream.py +++ b/slack_bolt/context/say_stream/say_stream.py @@ -34,6 +34,9 @@ def __call__( recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, thread_ts: Optional[str] = None, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: Optional[str] = None, **kwargs, ) -> ChatStream: """Starts a new chat stream with context.""" @@ -51,6 +54,9 @@ def __call__( 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, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, ) return self.client.chat_stream( @@ -58,5 +64,8 @@ def __call__( 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, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, ) diff --git a/slack_bolt/context/set_status/async_set_status.py b/slack_bolt/context/set_status/async_set_status.py index e2c451f46..f10cc195c 100644 --- a/slack_bolt/context/set_status/async_set_status.py +++ b/slack_bolt/context/set_status/async_set_status.py @@ -23,6 +23,9 @@ async def __call__( self, status: str, loading_messages: Optional[List[str]] = None, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: Optional[str] = None, **kwargs, ) -> AsyncSlackResponse: return await self.client.assistant_threads_setStatus( @@ -30,5 +33,8 @@ async def __call__( thread_ts=self.thread_ts, status=status, loading_messages=loading_messages, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, ) diff --git a/slack_bolt/context/set_status/set_status.py b/slack_bolt/context/set_status/set_status.py index 0ed612e16..055a5cab7 100644 --- a/slack_bolt/context/set_status/set_status.py +++ b/slack_bolt/context/set_status/set_status.py @@ -23,6 +23,9 @@ def __call__( self, status: str, loading_messages: Optional[List[str]] = None, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: Optional[str] = None, **kwargs, ) -> SlackResponse: return self.client.assistant_threads_setStatus( @@ -30,5 +33,8 @@ def __call__( thread_ts=self.thread_ts, status=status, loading_messages=loading_messages, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, ) diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 2cf7e361e..c0f3f5c31 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -49,7 +49,7 @@ def process( @staticmethod def _can_skip(mode: str, body: Dict[str, Any]) -> bool: - return mode == "socket_mode" or (body is not None and body.get("ssl_check") == "1") + return mode == "socket_mode" @staticmethod def _build_error_response() -> BoltResponse: diff --git a/tests/adapter_tests/asgi/test_asgi_http.py b/tests/adapter_tests/asgi/test_asgi_http.py index 72b6434bf..f9f106461 100644 --- a/tests/adapter_tests/asgi/test_asgi_http.py +++ b/tests/adapter_tests/asgi/test_asgi_http.py @@ -223,6 +223,59 @@ async def test_url_verification(self): assert response.headers.get("content-type") == "application/json;charset=utf-8" assert_auth_test_count(self, 1) + @pytest.mark.asyncio + async def test_content_length_multibyte_body(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack(text="Hello ☃") # snowman is 3 bytes in UTF-8 + + app.command("/hello-world")(command_handler) + + body = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + content_length = int(response.headers.get("content-length")) + actual_bytes = len(response.body.encode("utf-8")) + assert content_length == actual_bytes + + @pytest.mark.asyncio + async def test_multi_value_headers(self): + from slack_bolt.adapter.asgi.http_response import AsgiHttpResponse + + headers = { + "set-cookie": ["cookie1=value1; Path=/", "cookie2=value2; Path=/"], + "content-type": ["text/html; charset=utf-8"], + } + response = AsgiHttpResponse(status=200, headers=headers, body="OK") + + set_cookie_headers = [(name, value) for name, value in response.raw_headers if name == b"set-cookie"] + assert len(set_cookie_headers) == 2 + assert set_cookie_headers[0] == (b"set-cookie", b"cookie1=value1; Path=/") + assert set_cookie_headers[1] == (b"set-cookie", b"cookie2=value2; Path=/") + @pytest.mark.asyncio async def test_unsupported_method(self): app = App( diff --git a/tests/adapter_tests/asgi/test_asgi_lifespan.py b/tests/adapter_tests/asgi/test_asgi_lifespan.py index 488a990f1..5b48f6b8a 100644 --- a/tests/adapter_tests/asgi/test_asgi_lifespan.py +++ b/tests/adapter_tests/asgi/test_asgi_lifespan.py @@ -1,5 +1,6 @@ import pytest +from asgiref.testing import ApplicationCommunicator from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -59,6 +60,24 @@ async def test_shutdown(self): assert response.type == "lifespan.shutdown.complete" assert response.message == "" + @pytest.mark.asyncio + async def test_full_lifespan_cycle(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + scope = {"type": "lifespan", "asgi": {"version": "3.0", "spec_version": "2.3"}} + communicator = ApplicationCommunicator(SlackRequestHandler(app), scope) + + await communicator.send_input({"type": "lifespan.startup"}) + startup_response = await communicator.receive_output(timeout=1) + assert startup_response["type"] == "lifespan.startup.complete" + + await communicator.send_input({"type": "lifespan.shutdown"}) + shutdown_response = await communicator.receive_output(timeout=1) + assert shutdown_response["type"] == "lifespan.shutdown.complete" + @pytest.mark.asyncio async def test_failed_event(self): app = App( diff --git a/tests/adapter_tests/falcon/test_falcon.py b/tests/adapter_tests/falcon/test_falcon.py index d7841a24a..4a2f34d36 100644 --- a/tests/adapter_tests/falcon/test_falcon.py +++ b/tests/adapter_tests/falcon/test_falcon.py @@ -205,3 +205,17 @@ def test_oauth(self): response = client.simulate_get("/slack/install") assert response.status_code == 200 assert "https://slack.com/oauth/v2/authorize?state=" in response.text + + def test_get_no_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + api = new_falcon_app() + resource = SlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_get("/slack/events") + assert response.status_code == 404 + assert "The page is not found" in response.text diff --git a/tests/adapter_tests/socket_mode/test_interactions_builtin.py b/tests/adapter_tests/socket_mode/test_interactions_builtin.py index 2ecd52554..aec04d938 100644 --- a/tests/adapter_tests/socket_mode/test_interactions_builtin.py +++ b/tests/adapter_tests/socket_mode/test_interactions_builtin.py @@ -36,7 +36,7 @@ def teardown_method(self): def test_interactions(self): app = App(client=self.web_client) - result = {"shortcut": False, "command": False} + result = {"shortcut": False, "command": False, "message": False} @app.shortcut("do-something") def shortcut_handler(ack): @@ -48,6 +48,13 @@ def command_handler(ack): result["command"] = True ack() + @app.message("<@W111>") + def message_handler(ack, req): + result["message"] = req.headers.get("x-slack-retry-num") == ["1"] and req.headers.get( + "x-slack-retry-reason" + ) == ["timeout"] + ack() + handler = SocketModeHandler( app_token="xapp-A111-222-xyz", app=app, @@ -66,5 +73,6 @@ def command_handler(ack): time.sleep(2) assert result["shortcut"] is True assert result["command"] is True + assert result["message"] is True finally: handler.client.close() diff --git a/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py b/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py index ccaa89d3e..90e027f9d 100644 --- a/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py +++ b/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py @@ -37,7 +37,7 @@ def test_interactions(self): app = App(client=self.web_client) - result = {"shortcut": False, "command": False} + result = {"shortcut": False, "command": False, "message": False} @app.shortcut("do-something") def shortcut_handler(ack): @@ -49,6 +49,13 @@ def command_handler(ack): result["command"] = True ack() + @app.message("<@W111>") + def message_handler(ack, req): + result["message"] = req.headers.get("x-slack-retry-num") == ["1"] and req.headers.get( + "x-slack-retry-reason" + ) == ["timeout"] + ack() + handler = SocketModeHandler( app_token="xapp-A111-222-xyz", app=app, @@ -67,5 +74,6 @@ def command_handler(ack): time.sleep(2) assert result["shortcut"] is True assert result["command"] is True + assert result["message"] is True finally: handler.client.close() diff --git a/tests/adapter_tests/socket_mode/test_internals.py b/tests/adapter_tests/socket_mode/test_internals.py new file mode 100644 index 000000000..fede30b48 --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_internals.py @@ -0,0 +1,20 @@ +from slack_sdk.socket_mode.request import SocketModeRequest + +from slack_bolt.adapter.socket_mode.internals import build_headers, run_bolt_app + + +class TestSocketModeInternals: + def test_build_retry_headers_without_retry(self): + req = SocketModeRequest(type="events_api", envelope_id="e1", payload={"type": "event_callback"}) + assert build_headers(req) is None + + def test_build_retry_headers_with_retry(self): + req = SocketModeRequest( + type="events_api", + envelope_id="e1", + payload={"type": "event_callback"}, + retry_attempt=2, + retry_reason="http_timeout", + ) + headers = build_headers(req) + assert headers == {"x-slack-retry-num": "2", "x-slack-retry-reason": "http_timeout"} diff --git a/tests/adapter_tests/wsgi/test_wsgi_http.py b/tests/adapter_tests/wsgi/test_wsgi_http.py index 63ac62627..c7fca2e25 100644 --- a/tests/adapter_tests/wsgi/test_wsgi_http.py +++ b/tests/adapter_tests/wsgi/test_wsgi_http.py @@ -89,6 +89,47 @@ def command_handler(ack): assert response.headers.get("content-type") == "text/plain;charset=utf-8" assert_auth_test_count(self, 1) + def test_ssl_check_param_does_not_bypass_request_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ssl_check_enabled=False, + ) + command_called = False + + def command_handler(ack): + nonlocal command_called + command_called = True + ack() + + app.command("/hello-world")(command_handler) + + body = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + "&ssl_check=1" + ) + headers = self.build_raw_headers("0", body) + headers["x-slack-signature"] = "v0=invalid" + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "401 Unauthorized" + assert response.body == """{"error": "invalid request"}""" + assert command_called is False + def test_events(self): app = App( client=self.web_client, 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 e8077f10c..812806a8c 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -40,7 +40,7 @@ async def test_events(self): app = AsyncApp(client=self.web_client) - result = {"shortcut": False, "command": False} + result = {"shortcut": False, "command": False, "message": False} @app.shortcut("do-something") async def shortcut_handler(ack): @@ -52,6 +52,13 @@ async def command_handler(ack): result["command"] = True await ack() + @app.message("<@W111>") + async def message_handler(ack, req): + result["message"] = req.headers.get("x-slack-retry-num") == ["1"] and req.headers.get( + "x-slack-retry-reason" + ) == ["timeout"] + await ack() + handler = AsyncSocketModeHandler( app_token="xapp-A111-222-xyz", app=app, @@ -67,6 +74,7 @@ async def command_handler(ack): await asyncio.sleep(2) assert result["shortcut"] is True assert result["command"] is True + assert result["message"] is True finally: await handler.client.close() stop_socket_mode_server(self) diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py index 84d20b2f9..fc27150ee 100644 --- a/tests/adapter_tests_async/socket_mode/test_async_websockets.py +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -40,7 +40,7 @@ async def test_events(self): app = AsyncApp(client=self.web_client) - result = {"shortcut": False, "command": False} + result = {"shortcut": False, "command": False, "message": False} @app.shortcut("do-something") async def shortcut_handler(ack): @@ -52,6 +52,13 @@ async def command_handler(ack): result["command"] = True await ack() + @app.message("<@W111>") + async def message_handler(ack, req): + result["message"] = req.headers.get("x-slack-retry-num") == ["1"] and req.headers.get( + "x-slack-retry-reason" + ) == ["timeout"] + await ack() + handler = AsyncSocketModeHandler( app_token="xapp-A111-222-xyz", app=app, @@ -67,6 +74,7 @@ async def command_handler(ack): await asyncio.sleep(2) assert result["shortcut"] is True assert result["command"] is True + assert result["message"] is True finally: await handler.client.close() stop_socket_mode_server(self) diff --git a/tests/adapter_tests_async/test_async_falcon.py b/tests/adapter_tests_async/test_async_falcon.py index 6e3901fdf..d7a8c277a 100644 --- a/tests/adapter_tests_async/test_async_falcon.py +++ b/tests/adapter_tests_async/test_async_falcon.py @@ -2,8 +2,9 @@ from time import time from urllib.parse import quote +import pytest import falcon -from falcon import testing +from falcon.testing import ASGIConductor from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient @@ -55,7 +56,8 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": timestamp, } - def test_events(self): + @pytest.mark.asyncio + async def test_events(self): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, @@ -92,16 +94,17 @@ async def event_handler(): resource = AsyncSlackAppResource(app) api.add_route("/slack/events", resource) - client = testing.TestClient(api) - response = client.simulate_post( - "/slack/events", - body=body, - headers=self.build_headers(timestamp, body), - ) + async with ASGIConductor(api) as conductor: + response = await conductor.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) assert response.status_code == 200 assert_auth_test_count(self, 1) - def test_shortcuts(self): + @pytest.mark.asyncio + async def test_shortcuts(self): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, @@ -133,16 +136,17 @@ async def shortcut_handler(ack): resource = AsyncSlackAppResource(app) api.add_route("/slack/events", resource) - client = testing.TestClient(api) - response = client.simulate_post( - "/slack/events", - body=body, - headers=self.build_headers(timestamp, body), - ) + async with ASGIConductor(api) as conductor: + response = await conductor.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) assert response.status_code == 200 assert_auth_test_count(self, 1) - def test_commands(self): + @pytest.mark.asyncio + async def test_commands(self): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, @@ -174,16 +178,17 @@ async def command_handler(ack): resource = AsyncSlackAppResource(app) api.add_route("/slack/events", resource) - client = testing.TestClient(api) - response = client.simulate_post( - "/slack/events", - body=body, - headers=self.build_headers(timestamp, body), - ) + async with ASGIConductor(api) as conductor: + response = await conductor.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) assert response.status_code == 200 assert_auth_test_count(self, 1) - def test_oauth(self): + @pytest.mark.asyncio + async def test_oauth(self): app = AsyncApp( client=self.web_client, signing_secret=self.signing_secret, @@ -197,9 +202,24 @@ def test_oauth(self): resource = AsyncSlackAppResource(app) api.add_route("/slack/install", resource) - client = testing.TestClient(api) - response = client.simulate_get("/slack/install") + async with ASGIConductor(api) as conductor: + response = await conductor.simulate_get("/slack/install") assert response.status_code == 200 assert response.headers.get("content-type") == "text/html; charset=utf-8" assert response.headers.get("content-length") == "607" assert "https://slack.com/oauth/v2/authorize?state=" in response.text + + @pytest.mark.asyncio + async def test_get_no_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + async with ASGIConductor(api) as conductor: + response = await conductor.simulate_get("/slack/events") + assert response.status_code == 404 + assert "The page is not found" in response.text diff --git a/tests/mock_asgi_server.py b/tests/mock_asgi_server.py index e71a0d3cb..0b29a3e5a 100644 --- a/tests/mock_asgi_server.py +++ b/tests/mock_asgi_server.py @@ -1,28 +1,44 @@ -from typing import Iterable, Tuple, Union +from typing import Iterable, Tuple + +from asgiref.testing import ApplicationCommunicator + from slack_bolt.adapter.asgi.base_handler import BaseSlackRequestHandler ENCODING = "utf-8" class AsgiTestServerResponse: - def __init__(self): - self.status_code: int = None - self._headers: Iterable[Tuple[bytes, bytes]] = [] - self._body: bytearray = bytearray(b"") + def __init__( + self, + status_code: int, + headers: Iterable[Tuple[bytes, bytes]] = (), + body: bytes = b"", + ): + self.status_code = status_code + self._headers = headers + self._body = body @property - def body(self): + def body(self) -> str: return self._body.decode(ENCODING) @property - def headers(self): - return {header[0].decode(ENCODING): header[1].decode(ENCODING) for header in self._headers} + def headers(self) -> dict: + result = {} + for header in self._headers: + key = header[0].decode(ENCODING) + if key not in result: + result[key] = header[1].decode(ENCODING) + return result + + def get_headers_list(self, name: str) -> list: + return [header[1].decode(ENCODING) for header in self._headers if header[0].decode(ENCODING) == name] class AsgiTestServerLifespanResponse: - def __init__(self): - self.type: str = None - self.message: str = "" + def __init__(self, type: str, message: str = ""): + self.type = type + self.message = message class AsgiTestServer: @@ -61,22 +77,17 @@ async def http( }, ) - async def receive(): - return {"type": "http.request", "body": bytes(body, ENCODING), "more_body": False} + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({"type": "http.request", "body": bytes(body, ENCODING), "more_body": False}) - response = AsgiTestServerResponse() + response_start = await communicator.receive_output(timeout=1) + response_body = await communicator.receive_output(timeout=1) - async def send(event): - if event["type"] == "http.response.start": - response.status_code = event["status"] - response._headers = event["headers"] - elif event["type"] == "http.response.body": - response._body.extend(event["body"]) - else: - raise TypeError(f"Sent type {event['type']} in response {event} is not valid") - - await self.asgi_app(scope, receive, send) - return response + return AsgiTestServerResponse( + status_code=response_start["status"], + headers=response_start.get("headers", []), + body=response_body.get("body", b""), + ) async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse: """This implements the server side behavior of the lifespan event @@ -92,17 +103,20 @@ async def lifespan(self, event: str) -> AsgiTestServerLifespanResponse: }, ) - async def receive(): - return {"type": f"lifespan.{event}"} + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({"type": f"lifespan.{event}"}) - response = AsgiTestServerLifespanResponse() + result = await communicator.receive_output(timeout=1) - async def send(event: dict): - response.type = event["type"] - response.message = event.get("message", "") + # Send shutdown so the handler exits cleanly + if event == "startup": + await communicator.send_input({"type": "lifespan.shutdown"}) + await communicator.receive_output(timeout=1) - await self.asgi_app(scope, receive, send) - return response + return AsgiTestServerLifespanResponse( + type=result["type"], + message=result.get("message", ""), + ) async def websocket(self) -> None: """This is not implemented""" @@ -113,10 +127,6 @@ async def websocket(self) -> None: }, ) - async def receive(): - return {} - - async def send(event: dict): - print(event) - - await self.asgi_app(scope, receive, send) + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({}) + await communicator.receive_output(timeout=1) diff --git a/tests/mock_wsgi_server.py b/tests/mock_wsgi_server.py index a389a898e..7e8bad2e8 100644 --- a/tests/mock_wsgi_server.py +++ b/tests/mock_wsgi_server.py @@ -1,4 +1,7 @@ -from typing import Dict, Iterable, Optional, Tuple +import io +from typing import Any, Callable, Dict, List, Optional, Tuple +from wsgiref.util import setup_testing_defaults +from wsgiref.validate import validator from slack_bolt.adapter.wsgi import SlackRequestHandler @@ -6,37 +9,49 @@ class WsgiTestServerResponse: - def __init__(self): + def __init__(self) -> None: self.status: Optional[str] = None - self._headers: Iterable[Tuple[str, str]] = [] - self._body: Iterable[bytes] = [] + self._headers: List[Tuple[str, str]] = [] + self._body: List[bytes] = [] @property def headers(self) -> Dict[str, str]: return {header[0]: header[1] for header in self._headers} @property - def body(self, length: int = 0) -> str: - return "".join([chunk.decode(ENCODING) for chunk in self._body[length:]]) + def body(self) -> str: + return "".join([chunk.decode(ENCODING) for chunk in self._body]) class MockReadable: + """PEP 3333 compliant input stream. + + Implements read, readline, readlines, and __iter__ as required + by the WSGI specification for wsgi.input. + """ + def __init__(self, body: str): self.body = body - self._body = bytes(body, ENCODING) + self._stream = io.BytesIO(bytes(body, ENCODING)) def get_content_length(self) -> int: - return len(self._body) + return len(self.body.encode(ENCODING)) + + def read(self, size: int = -1) -> bytes: + if size == -1: + return self._stream.read() + return self._stream.read(size) + + def readline(self, size: int = -1) -> bytes: + if size == -1: + return self._stream.readline() + return self._stream.readline(size) - def read(self, size: int) -> bytes: - if size < 0: - raise ValueError("Size must be positive.") - if size == 0: - return b"" - # The body can only be read once - _body = self._body[:size] - self._body = b"" - return _body + def readlines(self, hint: int = -1) -> List[bytes]: + return self._stream.readlines(hint) + + def __iter__(self): + return iter(self._stream) class WsgiTestServer: @@ -44,29 +59,26 @@ def __init__( self, wsgi_app: SlackRequestHandler, root_path: str = "", - version: Tuple[int, int] = (1, 0), - multithread: bool = False, - multiprocess: bool = False, - run_once: bool = False, input_terminated: bool = True, - server_software: bool = "mock/0.0.0", + server_software: str = "mock/0.0.0", url_scheme: str = "https", remote_addr: str = "127.0.0.1", remote_port: str = "63263", ): self.root_path = root_path - self.wsgi_app = wsgi_app - self.environ = { - "wsgi.version": version, - "wsgi.multithread": multithread, - "wsgi.multiprocess": multiprocess, - "wsgi.run_once": run_once, - "wsgi.input_terminated": input_terminated, - "SERVER_SOFTWARE": server_software, - "wsgi.url_scheme": url_scheme, - "REMOTE_ADDR": remote_addr, - "REMOTE_PORT": remote_port, - } + self.wsgi_app = validator(wsgi_app) + self.environ: Dict[str, Any] = {} + setup_testing_defaults(self.environ) + self.environ.update( + { + "wsgi.input_terminated": input_terminated, + "wsgi.errors": io.StringIO(), + "SERVER_SOFTWARE": server_software, + "wsgi.url_scheme": url_scheme, + "REMOTE_ADDR": remote_addr, + "REMOTE_PORT": remote_port, + } + ) def http( self, @@ -101,16 +113,29 @@ def http( environ[f"HTTP_{header_key}"] = value if body is not None: - environ["wsgi.input"] = MockReadable(body) + readable = MockReadable(body) + environ["wsgi.input"] = readable if "CONTENT_LENGTH" not in environ: - environ["CONTENT_LENGTH"] = str(environ["wsgi.input"].get_content_length()) + environ["CONTENT_LENGTH"] = str(readable.get_content_length()) + else: + environ["wsgi.input"] = MockReadable("") response = WsgiTestServerResponse() - def start_response(status, headers): + def start_response( + status: str, + headers: List[Tuple[str, str]], + exc_info: Optional[Any] = None, + ) -> Callable[[bytes], object]: response.status = status response._headers = headers - - response._body = self.wsgi_app(environ=environ, start_response=start_response) + return lambda s: None + + iterator = self.wsgi_app(environ, start_response) + try: + response._body = list(iterator) + finally: + if hasattr(iterator, "close"): + iterator.close() return response diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index 1db13ecca..ae7e7c53b 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -93,6 +93,34 @@ def test_failure(self): assert response.status == 404 assert_auth_test_count(self, 1) + def test_ssl_check_param_does_not_bypass_request_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ssl_check_enabled=False, + ) + command_called = False + + def command_handler(ack): + nonlocal command_called + command_called = True + ack() + + app.command("/hello-world")(command_handler) + + request = BoltRequest( + body=f"{slash_command_body}&ssl_check=1", + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": ["v0=invalid"], + "x-slack-request-timestamp": ["0"], + }, + ) + response = app.dispatch(request) + assert response.status == 401 + assert response.body == """{"error": "invalid request"}""" + assert command_called is False + slash_command_body = ( "token=verification_token" diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index 1ac02bce7..918ccba87 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -100,6 +100,35 @@ async def test_failure(self): assert response.status == 404 await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio + async def test_ssl_check_param_does_not_bypass_request_verification(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ssl_check_enabled=False, + ) + command_called = False + + async def command_handler(ack): + nonlocal command_called + command_called = True + await ack() + + app.command("/hello-world")(command_handler) + + request = AsyncBoltRequest( + body=f"{slash_command_body}&ssl_check=1", + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": ["v0=invalid"], + "x-slack-request-timestamp": ["0"], + }, + ) + response = await app.async_dispatch(request) + assert response.status == 401 + assert response.body == """{"error": "invalid request"}""" + assert command_called is False + slash_command_body = ( "token=verification_token" diff --git a/tests/slack_bolt/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py index 29d244a65..04a52e419 100644 --- a/tests/slack_bolt/context/test_say_stream.py +++ b/tests/slack_bolt/context/test_say_stream.py @@ -1,21 +1,14 @@ import pytest +from unittest.mock import patch, MagicMock + from slack_sdk import WebClient from slack_bolt.context.say_stream.say_stream import SayStream -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) + self.web_client = WebClient(token="xoxb-valid") def test_missing_channel_raises(self): say_stream = SayStream(client=self.web_client, channel=None, thread_ts="111.222") @@ -35,16 +28,17 @@ def test_default_params(self): 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, - } + with patch.object(self.web_client, "chat_stream", return_value=MagicMock()) as mock_chat_stream: + say_stream() + mock_chat_stream.assert_called_once_with( + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + icon_emoji=None, + icon_url=None, + username=None, + ) def test_parameter_overrides(self): say_stream = SayStream( @@ -54,16 +48,17 @@ def test_parameter_overrides(self): 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, - } + with patch.object(self.web_client, "chat_stream", return_value=MagicMock()) as mock_chat_stream: + say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + mock_chat_stream.assert_called_once_with( + channel="C222", + recipient_team_id="T222", + recipient_user_id="U222", + thread_ts="333.444", + icon_emoji=None, + icon_url=None, + username=None, + ) def test_buffer_size_overrides(self): say_stream = SayStream( @@ -73,19 +68,41 @@ def test_buffer_size_overrides(self): 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", - ) + with patch.object(self.web_client, "chat_stream", return_value=MagicMock()) as mock_chat_stream: + say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + mock_chat_stream.assert_called_once_with( + buffer_size=100, + channel="C222", + recipient_team_id="T222", + recipient_user_id="U222", + thread_ts="333.444", + icon_emoji=None, + icon_url=None, + username=None, + ) - 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_authorship_overrides(self): + say_stream = SayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + with patch.object(self.web_client, "chat_stream", return_value=MagicMock()) as mock_chat_stream: + say_stream(icon_emoji=":maple_leaf:", username="Charlie Brown") + mock_chat_stream.assert_called_once_with( + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + icon_emoji=":maple_leaf:", + icon_url=None, + username="Charlie Brown", + ) diff --git a/tests/slack_bolt/context/test_set_status.py b/tests/slack_bolt/context/test_set_status.py index fe998df5e..bb5807e96 100644 --- a/tests/slack_bolt/context/test_set_status.py +++ b/tests/slack_bolt/context/test_set_status.py @@ -32,6 +32,15 @@ def test_set_status_loading_messages(self): ) assert response.status_code == 200 + def test_set_status_authorship(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_status( + status="Thinking...", + icon_emoji=":maple_leaf:", + username="Charlie Brown", + ) + assert response.status_code == 200 + def test_set_status_invalid(self): set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") with pytest.raises(TypeError): diff --git a/tests/slack_bolt/middleware/request_verification/test_request_verification.py b/tests/slack_bolt/middleware/request_verification/test_request_verification.py index 2c9adea43..ae163a84d 100644 --- a/tests/slack_bolt/middleware/request_verification/test_request_verification.py +++ b/tests/slack_bolt/middleware/request_verification/test_request_verification.py @@ -45,3 +45,18 @@ def test_invalid(self): resp = middleware.process(req=req, resp=resp, next=next) assert resp.status == 401 assert resp.body == """{"error": "invalid request"}""" + + def test_ssl_check_param_requires_valid_signature(self): + middleware = RequestVerification(signing_secret=self.signing_secret) + req = BoltRequest( + body="token=random&ssl_check=1", + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": ["v0=invalid"], + "x-slack-request-timestamp": ["0"], + }, + ) + resp = BoltResponse(status=404) + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 401 + assert resp.body == """{"error": "invalid request"}""" diff --git a/tests/slack_bolt_async/context/test_async_say_stream.py b/tests/slack_bolt_async/context/test_async_say_stream.py index 016549bd6..7ac084044 100644 --- a/tests/slack_bolt_async/context/test_async_say_stream.py +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -1,28 +1,20 @@ import pytest +from unittest.mock import patch, MagicMock + from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream -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 + self.web_client = AsyncWebClient(token="xoxb-valid") + yield finally: - cleanup_mock_web_api_server(self) restore_os_env(old_os_env) @pytest.mark.asyncio @@ -46,16 +38,22 @@ async def test_default_params(self): recipient_user_id="U111", thread_ts="111.222", ) - stream = await say_stream() + mock_chat_stream = MagicMock() - 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, - } + async def fake_chat_stream(**kwargs): + return mock_chat_stream(**kwargs) + + with patch.object(self.web_client, "chat_stream", side_effect=fake_chat_stream): + await say_stream() + mock_chat_stream.assert_called_once_with( + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + icon_emoji=None, + icon_url=None, + username=None, + ) @pytest.mark.asyncio async def test_parameter_overrides(self): @@ -66,16 +64,22 @@ async def test_parameter_overrides(self): 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") + mock_chat_stream = MagicMock() - 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, - } + async def fake_chat_stream(**kwargs): + return mock_chat_stream(**kwargs) + + with patch.object(self.web_client, "chat_stream", side_effect=fake_chat_stream): + await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + mock_chat_stream.assert_called_once_with( + channel="C222", + recipient_team_id="T222", + recipient_user_id="U222", + thread_ts="333.444", + icon_emoji=None, + icon_url=None, + username=None, + ) @pytest.mark.asyncio async def test_buffer_size_overrides(self): @@ -86,19 +90,52 @@ async def test_buffer_size_overrides(self): 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", + mock_chat_stream = MagicMock() + + async def fake_chat_stream(**kwargs): + return mock_chat_stream(**kwargs) + + with patch.object(self.web_client, "chat_stream", side_effect=fake_chat_stream): + await say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + mock_chat_stream.assert_called_once_with( + buffer_size=100, + channel="C222", + recipient_team_id="T222", + recipient_user_id="U222", + thread_ts="333.444", + icon_emoji=None, + icon_url=None, + username=None, + ) + + @pytest.mark.asyncio + async def test_authorship_overrides(self): + say_stream = AsyncSayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", ) + mock_chat_stream = MagicMock() + + async def fake_chat_stream(**kwargs): + return mock_chat_stream(**kwargs) - 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, - } + with patch.object(self.web_client, "chat_stream", side_effect=fake_chat_stream): + await say_stream(icon_emoji=":maple_leaf:", username="Charlie Brown") + mock_chat_stream.assert_called_once_with( + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + icon_emoji=":maple_leaf:", + icon_url=None, + username="Charlie Brown", + ) 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 e785ff89e..bcf1fcf19 100644 --- a/tests/slack_bolt_async/context/test_async_set_status.py +++ b/tests/slack_bolt_async/context/test_async_set_status.py @@ -40,6 +40,16 @@ async def test_set_status_loading_messages(self): ) assert response.status_code == 200 + @pytest.mark.asyncio + async def test_set_status_authorship(self): + set_status = AsyncSetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_status( + status="Thinking...", + icon_emoji=":maple_leaf:", + username="Charlie Brown", + ) + assert response.status_code == 200 + @pytest.mark.asyncio async def test_set_status_invalid(self): set_status = AsyncSetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") 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 c097dd146..28921bc87 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 @@ -50,3 +50,19 @@ async def test_invalid(self): resp = await middleware.async_process(req=req, resp=resp, next=next) assert resp.status == 401 assert resp.body == """{"error": "invalid request"}""" + + @pytest.mark.asyncio + async def test_ssl_check_param_requires_valid_signature(self): + middleware = AsyncRequestVerification(signing_secret="secret") + req = AsyncBoltRequest( + body="token=random&ssl_check=1", + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": ["v0=invalid"], + "x-slack-request-timestamp": ["0"], + }, + ) + resp = BoltResponse(status=404) + resp = await middleware.async_process(req=req, resp=resp, next=next) + assert resp.status == 401 + assert resp.body == """{"error": "invalid request"}"""