diff --git a/.claude/skills/managing-dependencies/SKILL.md b/.claude/skills/managing-dependencies/SKILL.md new file mode 100644 index 000000000..8f67fccd8 --- /dev/null +++ b/.claude/skills/managing-dependencies/SKILL.md @@ -0,0 +1,127 @@ +--- +name: managing-dependencies +description: >- + Use when adding, updating, pinning, or reviewing any dependency in this repo's requirements/*.txt files, including taking a Dependabot bump or writing a requirement line by hand. Also use when a pip install fails on the older Python jobs while newer ones pass, or when CI errors with "Could not find a version that satisfies", "No matching distribution found", "Requires-Python >=3.x", or "ResolutionImpossible". Triggers include: "add to requirements", "bump/update ", "pin ", "fix this dependabot PR", "CI can't install on Python 3.7/3.8/3.9", "requires-python error in the install step". This skill defines the layout and version-constraint conventions to follow; reach for it before hand-editing any requirements/*.txt file. +--- + +# Managing dependencies + +## Overview + +Every `requirements/*.txt` file in this repo follows one layout convention and one version-constraint pattern. This keeps a single set of pins working across the whole Python matrix and every diff readable. The mechanism for making one line behave differently per interpreter is [PEP 508](https://peps.python.org/pep-0508/) environment markers. That spec is the authority for the marker syntax used throughout this skill. + +Two facts about this repo drive everything below: + +- It supports **Python `>=3.7`** (`requires-python` and the classifiers in `pyproject.toml`). Its CI matrix in `.github/workflows/ci-build.yml` tests **CPython 3.7 through 3.14 only** — there is **no PyPy** (the classifiers list only `Programming Language :: Python :: Implementation :: CPython`). +- Many popular packages keep raising their minimum Python. A bump that raises a dependency's **lower bound** to a release requiring a newer Python makes pip's resolver find nothing installable on the old interpreters. The install step then fails there before tests even run. + +The convention resolves this without dropping old-Python support: pin each interpreter to the newest release it can actually install, using PEP 508 `python_version` markers. + +## File-layout convention + +Each file starts with a `# pip install -r requirements/.txt` header, then lists **one dependency per section**: the name of the dependency (as a `# name` header), an optional rationale note (starting with `# Note:`, explaining why a version is pinned or split), then the requirement line(s), separated from the next section by a blank line. This makes every pin self-documenting. + +``` +# pip install -r requirements/test.txt + +# pytest +pytest<9.2 + +# pytest-cov +# Note: only needed to evaluate coverage on the latest supported python version +pytest-cov>=7.1.0,<8; python_version >= "3.14" +``` + +Keep this layout when adding or editing dependencies. Never leave an empty trailing `;` (a fossil of a collapsed split; delete it — the old `pytest-asyncio<2;` line was exactly this). + +## Which files need Python-version markers + +A marker split is only needed for requirements files installed across the **full** Python matrix. Which file you are editing decides this. To see where a file is installed, read `.github/workflows/ci-build.yml`. It is the source of truth for which Python versions install which requirements files. Everything except `dev_tools.txt` is installed by the `unittest` matrix job across 3.7–3.14. + +| File | Installed on | Needs markers? | +| ----------------------------- | ----------------------------------------------------- | ----------------------------------- | +| `requirements/adapter_dev.txt` | full matrix (`unittest`; also `typecheck`/`codecov` @3.14) | **Yes, if a bump raises the floor** | +| `requirements/async_dev.txt` | full matrix (`unittest`; also `typecheck`/`codecov` @3.14) | **Yes, if a bump raises the floor** | +| `requirements/test.txt` | full matrix (`unittest`) | **Yes, if a bump raises the floor** | +| `requirements/test_adapter.txt` | full matrix (`unittest`; `codecov` @3.14) | **Yes, if a bump raises the floor** | +| `requirements/test_async.txt` | full matrix (`unittest`) | **Yes, if a bump raises the floor** | +| `requirements/dev_tools.txt` | `lint` + `typecheck`, **latest Python only** (3.14) | No, just take the bump (`==` pins) | + +If the bump lands in a latest-Python-only file, take it as-is: no markers, no ceiling, just the layout convention above. + +Unlike some sibling repos, bolt-python has **no requirements file that feeds packaged wheel metadata**: `pyproject.toml` has no `[project.optional-dependencies]`, and `[tool.setuptools.dynamic]` resolves only `version` and `readme`. So there is no "special" file whose comments leak into a wheel — every `requirements/*.txt` file is dev/test-only. + +## The version-constraint pattern + +When a dependency's floor rises to a release that requires a newer Python, **do not** just take the bump, and **do not** drop old-Python support to make CI pass. Instead, split the requirement into `python_version`-marked lines that **partition the whole matrix**. Every interpreter matches exactly one line. Old interpreters keep the last compatible release (with an explicit ceiling), and the newest line is open-ended so future Pythons stay covered. + +``` +# aiohttp +aiohttp>=3,<4; python_version < "3.9" +aiohttp>=3.13.5,<4; python_version >= "3.9" +``` + +`aiohttp` 3.13.5 requires Python `>=3.9`, so Python 3.7/3.8 stay on the older line. The same 3.9 split appears for `falcon`, `fastapi`, `Flask`, `Werkzeug`, `starlette`, `tornado`, `websocket_client`, and (in `test_async.txt`) `asgiref`. The `Django` split lands at 3.8 instead — Django 4.x requires `>=3.8`, so 3.7 keeps the 3.2 line: + +``` +# Django +Django>=3.2,<4; python_version < "3.8" +Django>=4.2.30,<6; python_version >= "3.8" +``` + +## Canonical marker style + +Consistency matters because these lines are read and edited often, and a stray style makes diffs noisy. Standardize on this: + +- Spaces around every operator in the marker: `python_version >= "3.9"`, never `python_version>="3.9"`. +- Double-quoted `major.minor` string: `"3.9"`. (`packaging` compares these version-aware, so `python_version >= "3.9"` correctly includes 3.10–3.14, no lexicographic surprise.) +- Use `>=` / `<` for the Python boundary; avoid `>` / `<=` so the boundary version lands on exactly one side. **This rule is about the `python_version` marker, not the version specifier** — `boto3<=2` and `cheroot<12` are correct as written. +- One space after the `;`, none before: `pkg>=1,<2; python_version >= "3.9"`. +- The old-side line always carries an explicit upper bound (the floor-jump version). +- The marker set must be **exhaustive and mutually exclusive** across the matrix. The newest line ends open-ended (`>= "X.Y"`), never a bare `==` that leaves future Pythons unmatched. + +## Deriving the versions to pin + +You need two numbers: the **floor** (which Python the new release requires) and the old-side **ceiling** (the first release that raised that floor). + +1. **Floor.** Read the metadata for the _exact target version_ at `https://pypi.org/pypi///json`. The `info.requires_python` field gives the new minimum (e.g. `">=3.9"`). A `null` there means the release declares no floor. + +2. **Ceiling.** Walk the release history at `https://pypi.org/pypi//json` and find the **first version that raised the floor above the oldest matrix Python**. The old-side ceiling is `< `. For example, if a package jumped to `>=3.9` at version **4.0.0**, the old-side cap is `<4` even if the target is `4.2.0` (pinning `<4.2.0` would wrongly admit 4.0.0–4.1.x, which are also 3.9-only). + +Why an explicit ceiling instead of trusting pip to filter by `Requires-Python`? Because that filtering only holds if every future release keeps its metadata correct; a single mis-tagged release would silently float onto an untested interpreter. An explicit ceiling makes the intent self-documenting and robust. + +`tracerite` is the cautionary case: its releases after 1.1.2 break on Python `<= 3.8`, yet those releases publish **no `requires_python` metadata at all** (verify: `https://pypi.org/pypi/tracerite/1.1.3/json` shows `requires_python: null`). So pip filtering offers zero protection on the old interpreters, and the explicit `tracerite<1.1.2` ceiling is load-bearing, not decorative. + +**If you arrived here from a red CI job:** the failing _install_ log is ground truth. It names the interpreter that failed and the versions pip was actually offered, e.g.: + +``` +ERROR: Ignored the following versions that require a different python version: 4.2.0 Requires-Python >=3.9 +ERROR: Could not find a version that satisfies the requirement falcon>=4.2.0 (from versions: ..., 3.1.3) +``` + +Cross-check the PyPI value against that log so you are never guessing. (If instead the failure is a real test failure, or hits _every_ Python version, this pattern does not apply, so investigate the bump normally.) + +## One harder shape: a coupled companion dependency + +Sometimes a package drags in another distribution with loose or wildcard versions that you must co-pin on the same boundary. `Sanic` imports `tracerite` with wildcard versions, so `tracerite` is pinned right beside it: + +``` +# sanic +# Note: Sanic pulls in tracerite via a wildcard version, so tracerite is co-pinned here. +# Note: tracerite > 1.1.2 is incompatible with Python <= 3.8 and ships no requires_python, so an explicit ceiling is required. +tracerite<1.1.2; python_version < "3.9" +sanic>=21,<24; python_version < "3.9" +sanic>=25.3.0,<26; python_version >= "3.9" +``` + +The split boundary here (3.9) is driven by `tracerite`, **not** by Sanic itself — Sanic 25.3 supports Python 3.8 (`requires_python: >=3.8`). Python 3.8 is kept on old Sanic only because tracerite breaks there. When a companion transitive dependency is the thing that breaks, pin it explicitly rather than hoping the parent's resolver picks a compatible version, and keep it in the same section so the coupling stays visible. + +## Collapse when a Python is dropped + +Marker splits are maintenance cost, so remove them when they stop earning their keep. When a Python version is dropped from the CI matrix (and from `requires-python` / the classifiers), collapse any split whose only reason was that version back into a single unmarked line, and delete the trailing `;`. For example, if 3.7 is dropped, the `Django>=3.2,<4; python_version < "3.8"` line has no interpreter left to serve, and the section collapses to a single `Django` line. A leaner file is easier for both humans and Dependabot to reason about. + +## What to leave alone + +- **Do not touch `requires-python` or the CI matrix.** Keeping 3.7 working on old dependency versions is the entire point; changing the floor is a separate, deliberate decision. +- **Do not add runtime dependencies to `pyproject.toml`.** The core package depends only on `slack_sdk` (see the "Single Runtime Dependency Rule" in `AGENTS.md`); everything else belongs in `requirements/*.txt`. +- **Prefer markers over a Dependabot `ignore`.** An `ignore` rule freezes newer Pythons on the old version too, and hides the version knowledge in config. Reserve `ignore` for the rare dep that must stay pinned everywhere for reproducible output. 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 6555a6531..dd2119b8c 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -20,11 +20,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - name: Run lint verification @@ -37,15 +37,26 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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/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_dev.txt + pip install -r requirements/adapter_dev.txt + - name: Type check all modules + run: mypy --config-file pyproject.toml unittest: name: Unit tests @@ -66,26 +77,26 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Install synchronous dependencies 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/ \ @@ -94,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 @@ -115,10 +126,10 @@ 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: directory: ./reports/ - fail_ci_if_error: true + fail_ci_if_error: false flags: ${{ matrix.python-version }} report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} @@ -133,25 +144,25 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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 + 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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: fail_ci_if_error: true report_type: coverage @@ -168,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@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 with: errors: true webhook: ${{ secrets.SLACK_REGRESSION_FAILURES_WEBHOOK_URL }} diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 824d57701..aafc2c0af 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@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Approve diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 7ec974574..2d0f7630a 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,13 +18,13 @@ jobs: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.release.tag_name || github.ref }} persist-credentials: false - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" @@ -33,7 +33,7 @@ jobs: scripts/build_pypi_package.sh - name: Persist dist folder - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-dist path: dist/ @@ -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/ @@ -60,7 +60,7 @@ jobs: - name: Publish release distributions to test.pypi.org # Using OIDC for PyPI publishing (no API tokens needed) # See: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-pypi - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ @@ -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/ @@ -84,4 +84,4 @@ jobs: - name: Publish release distributions to pypi.org # Using OIDC for PyPI publishing (no API tokens needed) # See: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-pypi - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index c29bface2..adad546b8 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@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: days-before-issue-stale: 30 days-before-issue-close: 10 diff --git a/AGENTS.md b/AGENTS.md index 57f2fa588..eae86f330 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 @@ -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,15 +205,17 @@ 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). +Before adding, bumping, pinning, or reviewing any dependency in `requirements/*.txt` -- whether a Dependabot PR or a manual edit -- follow the `managing-dependencies` skill in `.claude/skills/`. It defines the layout and `python_version` marker conventions that keep one set of pins working across the full CPython 3.7--3.14 matrix. + ## Test Organization and CI ### Directory Structure diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..06dfdb664 --- /dev/null +++ b/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/docs/english/_sidebar.json b/docs/english/_sidebar.json index eab9d94f8..aa75b0c15 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", @@ -195,21 +202,5 @@ "items": ["tools/bolt-python/ja-jp/legacy/steps-from-apps"] } ] - }, - { "type": "html", "value": "
" }, - { - "type": "link", - "label": "Release notes", - "href": "https://github.com/slackapi/bolt-python/releases" - }, - { - "type": "link", - "label": "Code on GitHub", - "href": "https://github.com/SlackAPI/bolt-python" - }, - { - "type": "link", - "label": "Contributors Guide", - "href": "https://github.com/SlackAPI/bolt-python/blob/main/.github/contributing.md" } ] diff --git a/docs/english/concepts/adding-agent-features.md b/docs/english/concepts/adding-agent-features.md new file mode 100644 index 000000000..f420a219a --- /dev/null +++ b/docs/english/concepts/adding-agent-features.md @@ -0,0 +1,793 @@ +--- +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/using-the-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** 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 slack_bolt import BoltContext, Say, SayStream, SetStatus +from slack_sdk import WebClient + +from agent import CaseyDeps, run_casey +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: + client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + + ... +``` + + + + + +:::tip[Using the Assistant side panel] +The assistant messaging experience requires additional setup. See the [Assistant class guide](/tools/bolt-python/concepts/using-the-assistant-class). +::: + +How you greet a user and set suggested prompts when they open the agent's DM depends on which [messaging experience](/ai/developing-agents) your app uses: + +* The **agent messaging experience** (`agent_view`) is the default and the only experience available to apps going forward. Conversations happen in the app's **Messages** tab, so you listen for the [`app_home_opened`](/reference/events/app_home_opened) event and check for the `messages` tab to detect when a user opens the DM, then set suggested prompts at the top of the tab (no `thread_ts` required). Casey uses this approach. +* The **assistant messaging experience** (`assistant_view`) is the legacy experience, with separate **Chat** and **History** tabs. You listen for the [`assistant_thread_started`](/reference/events/assistant_thread_started) event to detect a thread, then set suggested prompts on it. Existing apps can continue to use this but should migrate to the agent messaging experience. + +Refer to the [agent messaging experience changelog entry](/changelog/2026/06/30/agent-messages-tab) for the full list of changes and a migration checklist. + + + + +```python +from logging import Logger + +from slack_bolt import BoltContext +from slack_sdk import WebClient + +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_app_home_opened( + client: WebClient, event: dict, context: BoltContext, logger: Logger +): + """Handle app_home_opened events. + + Under agent_view, this event fires for both the Home tab and the Messages + tab (the agent DM). Branch on ``event["tab"]``. + """ + try: + if event.get("tab") == "messages": + # Suggested prompts pin to the top of the Messages tab; no thread_ts required. + client.assistant_threads_setSuggestedPrompts( + channel_id=event["channel"], + title="How can I help you today?", + prompts=SUGGESTED_PROMPTS, + ) + return + + # event["tab"] == "home": publish your App Home Block Kit view here + except Exception as e: + logger.exception(f"Failed to handle app_home_opened: {e}") +``` + + + + +```python +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 either `channel_id` or `thread_ts` cannot be sourced, 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, run_casey +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"], + user_token=context.user_token, + ) + result = run_casey(cleaned_text, 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"], + user_token=context.user_token, + ) + 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}") + 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 import BoltContext, Say, SayStream, SetStatus +from slack_sdk import WebClient + +from agent import CaseyDeps, run_casey +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"], + user_token=context.user_token, + ) + result = run_casey(input_items, 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/authorization.md b/docs/english/concepts/authorization.md index f6a258491..9d0086ca7 100644 --- a/docs/english/concepts/authorization.md +++ b/docs/english/concepts/authorization.md @@ -2,9 +2,10 @@ Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack request. -Apps installed on a single workspace can simply pass their bot token into the `App` constructor using the `token` parameter. However, if your app will be installed on multiple workspaces, you have two options. The easier option is to use the built-in OAuth support. This will handle setting up OAuth routes and verifying state. Read the section on [authenticating with OAuth](/tools/bolt-python/concepts/authenticating-oauth) for details. +Apps installed on a single workspace can pass their bot token into the `App` constructor using the `token` parameter. However, if your app will be installed on multiple workspaces, you have two options: -For a more custom solution, you can set the `authorize` parameter to a function upon `App` instantiation. The `authorize` function should return [an instance of `AuthorizeResult`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py), which contains information about who and where the request is coming from. +* Use the built-in OAuth support. This will handle setting up OAuth routes and verifying state. See [authenticating with OAuth](/tools/bolt-python/concepts/authenticating-oauth) for more details. +* Set the `authorize` parameter to a function upon `App` instantiation. The `authorize` function should return [an instance of `AuthorizeResult`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py), which contains information about who and where the request is coming from. `AuthorizeResult` should have a few specific properties, all of type `str`: - Either **`bot_token`** (xoxb) *or* **`user_token`** (xoxp) are **required**. Most apps will use `bot_token` by default. Passing a token allows built-in functions (like `say()`) to work. @@ -62,3 +63,11 @@ app = App( authorize=authorize ) ``` + +## Handling failed token lookups {#handling-failed-token-lookups} + +In the event that you receive events from disconnected teams, make sure to gracefully drop these unauthorized payloads by returning `None` to silently drop the payload as follows. + +In the custom `authorize` callback, if a token lookup fails due to an `unknown team_id`, you should return `None` rather than raising an exception (such as `BoltUnauthorizedError`). Bolt's authorization middleware will recognize the `None` response as an authorization failure and immediately halt execution, silently dropping the payload without generating server error logs. + +Additionally, the `user_facing_authorize_error_message` parameter strictly controls the ephemeral message sent back to the end user via the Slack UI. It does not suppress internal server logs or exceptions. To achieve both the desired UI behavior and clean server logs, pair this parameter with returning `None` in your authorize function. diff --git a/docs/english/concepts/message-sending.md b/docs/english/concepts/message-sending.md index 87c433129..c4d1b0467 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 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. + +### 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/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/ai-apps.md b/docs/english/concepts/using-the-assistant-class.md similarity index 64% rename from docs/english/concepts/ai-apps.md rename to docs/english/concepts/using-the-assistant-class.md index 3b057bc7e..55ca3d110 100644 --- a/docs/english/concepts/ai-apps.md +++ b/docs/english/concepts/using-the-assistant-class.md @@ -1,17 +1,14 @@ - -# 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** feature enabled. + +:::warning[The `Assistant` class targets the assistant messaging experience] +The `Assistant` class handles the [assistant messaging experience](/ai/developing-agents) (`assistant_view`), in which agent conversations happen in separate Chat and History tabs. Apps going forward use the agent messaging experience (`agent_view`) by default, where conversations happen in the Messages tab and you handle events such as [`app_home_opened`](/reference/events/app_home_opened) and [`message.im`](/reference/events/message.im) directly. Refer to [Adding agent features](/tools/bolt-python/concepts/adding-agent-features) and the [agent messaging experience changelog entry](/changelog/2026/06/30/agent-messages-tab) for the default experience and migration guidance. +::: A typical flow would look like: @@ -58,14 +55,14 @@ 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.] ::: -### 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. +1. Within [App Settings](https://api.slack.com/apps), enable the **Agents** feature. 2. Within the App Settings **OAuth & Permissions** page, add the following scopes: * [`assistant:write`](/reference/scopes/assistant.write) @@ -77,7 +74,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 +119,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 +134,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. @@ -145,10 +142,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 @@ -205,7 +202,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 +328,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 [Starter Agent Template](https://github.com/slack-samples/bolt-python-starter-agent) repo for you to build from. \ No newline at end of file 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/building-an-app.md b/docs/english/creating-an-app.md similarity index 98% rename from docs/english/building-an-app.md rename to docs/english/creating-an-app.md index bde340961..66e1febee 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] @@ -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](/security). Your app uses tokens to post and retrieve information from Slack workspaces. +Treat your tokens like passwords and [keep them safe](/concepts/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](/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](/concepts/security). ::: diff --git a/docs/english/experiments.md b/docs/english/experiments.md index 681c8cbc6..443a334be 100644 --- a/docs/english/experiments.md +++ b/docs/english/experiments.md @@ -1,34 +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() -``` - -### 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 +There are currently no active experiments. We're steadily staying stable. diff --git a/docs/english/getting-started.md b/docs/english/getting-started.md index 934dd3bae..8cfd7faf8 100644 --- a/docs/english/getting-started.md +++ b/docs/english/getting-started.md @@ -12,7 +12,7 @@ When complete, you'll have a local environment configured with a customized [app :::tip[Reference for readers] -In search of the complete guide to building an app from scratch? Check out the [building an app](/tools/bolt-python/building-an-app) guide. +In search of the complete guide to building an app from scratch? Check out the [building an app](/tools/bolt-python/creating-an-app) guide. ::: @@ -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](/security). Your app uses these to retrieve and send information to Slack. +Treat your tokens like a password and [keep it safe](/concepts/security). Your app uses these to retrieve and send information to Slack. ::: @@ -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/index.md b/docs/english/index.md index 212bd9690..60df4ec1c 100644 --- a/docs/english/index.md +++ b/docs/english/index.md @@ -13,6 +13,10 @@ If you otherwise get stuck, we're here to help. The following are the best ways * [Issue Tracker](http://github.com/slackapi/bolt-python/issues) for questions, bug reports, feature requests, and general discussion related to Bolt for Python. Try searching for an existing issue before creating a new one. * [Email](mailto:support@slack.com) our developer support team: `support@slack.com`. +## Release notes + +Check out the [Bolt for Python release notes](https://github.com/slackapi/bolt-python/releases) for all the latest happenings. + ## Contributing These docs live within the [Bolt-Python](https://github.com/slackapi/bolt-python/) repository and are open source. 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/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/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 diff --git a/docs/japanese/getting-started.md b/docs/japanese/getting-started.md index 41e6ae5cd..46ac55ffb 100644 --- a/docs/japanese/getting-started.md +++ b/docs/japanese/getting-started.md @@ -48,7 +48,7 @@ Slack アプリで使用できるトークンには、ユーザートークン 6. 左サイドメニューの「**Socket Mode**」を有効にします。 -:::tip[トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](/security)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。] +:::tip[トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](/concepts/security)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。] ::: @@ -91,7 +91,7 @@ export SLACK_APP_TOKEN=<アプリレベルトークン> ``` :::warning[🔒 全てのトークンは安全に保管してください。] -少なくともパブリックなバージョン管理にチェックインするようなことは避けるべきでしょう。また、上にあった例のように環境変数を介してアクセスするようにしてください。詳細な情報は [アプリのセキュリティのベストプラクティス](/security)のドキュメントを参照してください。 +少なくともパブリックなバージョン管理にチェックインするようなことは避けるべきでしょう。また、上にあった例のように環境変数を介してアクセスするようにしてください。詳細な情報は [アプリのセキュリティのベストプラクティス](/concepts/security)のドキュメントを参照してください。 ::: diff --git a/docs/reference/adapter/asgi/base_handler.html b/docs/reference/adapter/asgi/base_handler.html index b8a6da68f..74358683e 100644 --- a/docs/reference/adapter/asgi/base_handler.html +++ b/docs/reference/adapter/asgi/base_handler.html @@ -88,15 +88,13 @@

Classes

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": @@ -107,7 +105,7 @@

Classes

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/docs/reference/adapter/asgi/http_response.html b/docs/reference/adapter/asgi/http_response.html index 86e368f6e..0c42d9a9f 100644 --- a/docs/reference/adapter/asgi/http_response.html +++ b/docs/reference/adapter/asgi/http_response.html @@ -60,11 +60,14 @@

Classes

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 { @@ -94,11 +97,14 @@

Instance variables

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 { @@ -127,11 +133,14 @@

Instance variables

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 { @@ -160,11 +169,14 @@

Instance variables

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/docs/reference/adapter/falcon/async_resource.html b/docs/reference/adapter/falcon/async_resource.html index f43ab11ef..0dbba1ad4 100644 --- a/docs/reference/adapter/falcon/async_resource.html +++ b/docs/reference/adapter/falcon/async_resource.html @@ -85,9 +85,9 @@

Classes

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) @@ -149,9 +149,9 @@

Methods

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..."
diff --git a/docs/reference/adapter/falcon/index.html b/docs/reference/adapter/falcon/index.html index 82a2a57e2..bfc21828f 100644 --- a/docs/reference/adapter/falcon/index.html +++ b/docs/reference/adapter/falcon/index.html @@ -91,9 +91,9 @@

Classes

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) @@ -159,9 +159,9 @@

Methods

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..."
diff --git a/docs/reference/adapter/falcon/resource.html b/docs/reference/adapter/falcon/resource.html index 73860adc1..13f9a2177 100644 --- a/docs/reference/adapter/falcon/resource.html +++ b/docs/reference/adapter/falcon/resource.html @@ -80,9 +80,9 @@

Classes

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) @@ -148,9 +148,9 @@

Methods

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..."
diff --git a/docs/reference/adapter/socket_mode/async_internals.html b/docs/reference/adapter/socket_mode/async_internals.html index d2e300efa..c0b23b1de 100644 --- a/docs/reference/adapter/socket_mode/async_internals.html +++ b/docs/reference/adapter/socket_mode/async_internals.html @@ -54,7 +54,7 @@

Functions

Expand source code
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/docs/reference/adapter/socket_mode/internals.html b/docs/reference/adapter/socket_mode/internals.html index 55d96b054..ba7d2f226 100644 --- a/docs/reference/adapter/socket_mode/internals.html +++ b/docs/reference/adapter/socket_mode/internals.html @@ -45,6 +45,25 @@

Module slack_bolt.adapter.socket_mode.internals

Functions

+
+def build_headers(req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> Dict[str, str | Sequence[str]] | None +
+
+
+ +Expand source code + +
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: slack_sdk.socket_mode.request.SocketModeRequest)
@@ -54,7 +73,7 @@

Functions

Expand source code
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
@@ -111,6 +130,7 @@

Functions

  • Functions

    diff --git a/docs/reference/adapter/wsgi/handler.html b/docs/reference/adapter/wsgi/handler.html index 204499a05..a6ea85ca4 100644 --- a/docs/reference/adapter/wsgi/handler.html +++ b/docs/reference/adapter/wsgi/handler.html @@ -117,17 +117,20 @@

    Classes

    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()

    Setup Bolt as a WSGI web framework, this will make your application compatible with WSGI web servers. This can be used for production deployments.

    diff --git a/docs/reference/adapter/wsgi/http_request.html b/docs/reference/adapter/wsgi/http_request.html index fa845dd93..72c5f28be 100644 --- a/docs/reference/adapter/wsgi/http_request.html +++ b/docs/reference/adapter/wsgi/http_request.html @@ -48,7 +48,7 @@

    Classes

    class WsgiHttpRequest -(environ: Dict[str, Any]) +(environ: WSGIEnvironment)
    @@ -64,7 +64,7 @@

    Classes

    __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", "") @@ -85,7 +85,7 @@

    Classes

    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)

    This Class uses the PEP 3333 standard to extract request information @@ -108,7 +108,7 @@

    Instance variables

    __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", "") @@ -129,7 +129,7 @@

    Instance variables

    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)
    @@ -149,7 +149,7 @@

    Instance variables

    __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", "") @@ -170,7 +170,7 @@

    Instance variables

    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)
    @@ -190,7 +190,7 @@

    Instance variables

    __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", "") @@ -211,7 +211,7 @@

    Instance variables

    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)
    @@ -231,7 +231,7 @@

    Instance variables

    __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", "") @@ -252,7 +252,7 @@

    Instance variables

    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)
    @@ -272,7 +272,7 @@

    Instance variables

    __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", "") @@ -293,7 +293,7 @@

    Instance variables

    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)
    @@ -312,7 +312,7 @@

    Methods

    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/docs/reference/adapter/wsgi/http_response.html b/docs/reference/adapter/wsgi/http_response.html index da7dc33f0..726332c77 100644 --- a/docs/reference/adapter/wsgi/http_response.html +++ b/docs/reference/adapter/wsgi/http_response.html @@ -48,7 +48,7 @@

    Classes

    class WsgiHttpResponse -(status: int, headers: Dict[str, Sequence[str]] = {}, body: str = '') +(status: int, headers: Dict[str, Sequence[str]] | None = None, body: str = '')
    @@ -64,18 +64,19 @@

    Classes

    __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 @@ -103,18 +104,19 @@

    Instance variables

    __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 @@ -150,10 +152,11 @@

    Methods

    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/docs/reference/adapter/wsgi/index.html b/docs/reference/adapter/wsgi/index.html index c3cfafea1..186d1adf6 100644 --- a/docs/reference/adapter/wsgi/index.html +++ b/docs/reference/adapter/wsgi/index.html @@ -136,17 +136,20 @@

    Classes

    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()

    Setup Bolt as a WSGI web framework, this will make your application compatible with WSGI web servers. This can be used for production deployments.

    diff --git a/docs/reference/app/app.html b/docs/reference/app/app.html index c91d020ef..737597548 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. @@ -117,7 +118,7 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. + Refer to https://docs.slack.dev/tools/bolt-python/creating-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. @@ -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, @@ -1445,7 +1438,7 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) -

    Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/creating-an-app for details.

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

    Args

    @@ -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..5581b98e7 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. @@ -136,7 +137,7 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. + Refer to https://docs.slack.dev/tools/bolt-python/creating-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. @@ -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, @@ -1464,7 +1457,7 @@

    Classes

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) -

    Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/creating-an-app for details.

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

    Args

    @@ -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..670ba58d0 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,106 @@

    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,
    +        icon_emoji: Optional[str] = None,
    +        icon_url: Optional[str] = None,
    +        username: 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,
    +                icon_emoji=icon_emoji,
    +                icon_url=icon_url,
    +                username=username,
    +                **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,
    +            icon_emoji=icon_emoji,
    +            icon_url=icon_url,
    +            username=username,
    +            **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)
    @@ -5259,6 +5380,9 @@

    Class variables

    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( @@ -5266,6 +5390,9 @@

    Class variables

    thread_ts=self.thread_ts, status=status, loading_messages=loading_messages, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, )
    @@ -5288,7 +5415,7 @@

    Class variables

    class AsyncSetSuggestedPrompts -(client: slack_sdk.web.async_client.AsyncWebClient,
    channel_id: str,
    thread_ts: str)
    +(client: slack_sdk.web.async_client.AsyncWebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -5298,13 +5425,13 @@

    Class variables

    class AsyncSetSuggestedPrompts:
         client: AsyncWebClient
         channel_id: str
    -    thread_ts: str
    +    thread_ts: Optional[str]
     
         def __init__(
             self,
             client: AsyncWebClient,
             channel_id: str,
    -        thread_ts: str,
    +        thread_ts: Optional[str] = None,
         ):
             self.client = client
             self.channel_id = channel_id
    @@ -5314,6 +5441,7 @@ 

    Class variables

    self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -5324,7 +5452,7 @@

    Class variables

    return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -5340,7 +5468,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    @@ -5485,6 +5613,7 @@

    respond

  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • @@ -5565,6 +5694,16 @@

    AsyncSayStream

    + + +
  • AsyncSetStatus

    • channel_id
    • diff --git a/docs/reference/context/assistant/assistant_utilities.html b/docs/reference/context/assistant/assistant_utilities.html index d446b3c02..2200c4f10 100644 --- a/docs/reference/context/assistant/assistant_utilities.html +++ b/docs/reference/context/assistant/assistant_utilities.html @@ -86,21 +86,10 @@

      Classes

      # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> SetStatus: - return SetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> SetTitle: return SetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> SetSuggestedPrompts: - return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> Say: def build_metadata() -> Optional[dict]: @@ -197,30 +186,6 @@

      Instance variables

      -
      prop set_statusSetStatus
      -
      -
      - -Expand source code - -
      @property
      -def set_status(self) -> SetStatus:
      -    return SetStatus(self.client, self.channel_id, self.thread_ts)
      -
      -
      -
      -
      prop set_suggested_promptsSetSuggestedPrompts
      -
      -
      - -Expand source code - -
      @property
      -def set_suggested_prompts(self) -> SetSuggestedPrompts:
      -    return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
      -
      -
      -
      prop set_titleSetTitle
      @@ -234,22 +199,6 @@

      Instance variables

  • -

    Methods

    -
    -
    -def is_valid(self) ‑> bool -
    -
    -
    - -Expand source code - -
    def is_valid(self) -> bool:
    -    return self.channel_id is not None and self.thread_ts is not None
    -
    -
    -
    -
    @@ -272,12 +221,9 @@

    channel_id
  • client
  • get_thread_context
  • -
  • is_valid
  • payload
  • save_thread_context
  • say
  • -
  • set_status
  • -
  • set_suggested_prompts
  • set_title
  • thread_context_store
  • thread_ts
  • diff --git a/docs/reference/context/assistant/async_assistant_utilities.html b/docs/reference/context/assistant/async_assistant_utilities.html index fc3cbbe8b..70f4d0d23 100644 --- a/docs/reference/context/assistant/async_assistant_utilities.html +++ b/docs/reference/context/assistant/async_assistant_utilities.html @@ -86,21 +86,10 @@

    Classes

    # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> AsyncSetStatus: - return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> AsyncSetTitle: return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts: - return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> AsyncSay: return AsyncSay( @@ -191,30 +180,6 @@

    Instance variables

    -
    prop set_statusAsyncSetStatus
    -
    -
    - -Expand source code - -
    @property
    -def set_status(self) -> AsyncSetStatus:
    -    return AsyncSetStatus(self.client, self.channel_id, self.thread_ts)
    -
    -
    -
    -
    prop set_suggested_promptsAsyncSetSuggestedPrompts
    -
    -
    - -Expand source code - -
    @property
    -def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts:
    -    return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
    -
    -
    -
    prop set_titleAsyncSetTitle
    @@ -228,22 +193,6 @@

    Instance variables

    -

    Methods

    -
    -
    -def is_valid(self) ‑> bool -
    -
    -
    - -Expand source code - -
    def is_valid(self) -> bool:
    -    return self.channel_id is not None and self.thread_ts is not None
    -
    -
    -
    -
    @@ -266,12 +215,9 @@

    channel_id
  • client
  • get_thread_context
  • -
  • is_valid
  • payload
  • save_thread_context
  • say
  • -
  • set_status
  • -
  • set_suggested_prompts
  • set_title
  • thread_context_store
  • thread_ts
  • 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..3a1978299 --- /dev/null +++ b/docs/reference/context/say_stream/async_say_stream.html @@ -0,0 +1,183 @@ + + + + + + +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,
    +        icon_emoji: Optional[str] = None,
    +        icon_url: Optional[str] = None,
    +        username: 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,
    +                icon_emoji=icon_emoji,
    +                icon_url=icon_url,
    +                username=username,
    +                **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,
    +            icon_emoji=icon_emoji,
    +            icon_url=icon_url,
    +            username=username,
    +            **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..5ed62587b --- /dev/null +++ b/docs/reference/context/say_stream/index.html @@ -0,0 +1,200 @@ + + + + + + +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,
    +        icon_emoji: Optional[str] = None,
    +        icon_url: Optional[str] = None,
    +        username: 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,
    +                icon_emoji=icon_emoji,
    +                icon_url=icon_url,
    +                username=username,
    +                **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,
    +            icon_emoji=icon_emoji,
    +            icon_url=icon_url,
    +            username=username,
    +            **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..e7bc33bff --- /dev/null +++ b/docs/reference/context/say_stream/say_stream.html @@ -0,0 +1,183 @@ + + + + + + +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,
    +        icon_emoji: Optional[str] = None,
    +        icon_url: Optional[str] = None,
    +        username: 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,
    +                icon_emoji=icon_emoji,
    +                icon_url=icon_url,
    +                username=username,
    +                **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,
    +            icon_emoji=icon_emoji,
    +            icon_url=icon_url,
    +            username=username,
    +            **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/set_status/async_set_status.html b/docs/reference/context/set_status/async_set_status.html index 06efd6447..770583e4a 100644 --- a/docs/reference/context/set_status/async_set_status.html +++ b/docs/reference/context/set_status/async_set_status.html @@ -74,6 +74,9 @@

    Classes

    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( @@ -81,6 +84,9 @@

    Classes

    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/docs/reference/context/set_status/index.html b/docs/reference/context/set_status/index.html index aa11815e3..380e37f4f 100644 --- a/docs/reference/context/set_status/index.html +++ b/docs/reference/context/set_status/index.html @@ -85,6 +85,9 @@

    Classes

    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( @@ -92,6 +95,9 @@

    Classes

    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/docs/reference/context/set_status/set_status.html b/docs/reference/context/set_status/set_status.html index e4d839f64..b0a0a9ee7 100644 --- a/docs/reference/context/set_status/set_status.html +++ b/docs/reference/context/set_status/set_status.html @@ -74,6 +74,9 @@

    Classes

    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( @@ -81,6 +84,9 @@

    Classes

    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/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html index 4feda52ba..1c7656456 100644 --- a/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html @@ -48,7 +48,7 @@

    Classes

    class AsyncSetSuggestedPrompts -(client: slack_sdk.web.async_client.AsyncWebClient,
    channel_id: str,
    thread_ts: str)
    +(client: slack_sdk.web.async_client.AsyncWebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -58,13 +58,13 @@

    Classes

    class AsyncSetSuggestedPrompts:
         client: AsyncWebClient
         channel_id: str
    -    thread_ts: str
    +    thread_ts: Optional[str]
     
         def __init__(
             self,
             client: AsyncWebClient,
             channel_id: str,
    -        thread_ts: str,
    +        thread_ts: Optional[str] = None,
         ):
             self.client = client
             self.channel_id = channel_id
    @@ -74,6 +74,7 @@ 

    Classes

    self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -84,7 +85,7 @@

    Classes

    return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -100,7 +101,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    diff --git a/docs/reference/context/set_suggested_prompts/index.html b/docs/reference/context/set_suggested_prompts/index.html index 12d864dde..cf606ae2f 100644 --- a/docs/reference/context/set_suggested_prompts/index.html +++ b/docs/reference/context/set_suggested_prompts/index.html @@ -59,7 +59,7 @@

    Classes

    class SetSuggestedPrompts -(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +(client: slack_sdk.web.client.WebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -69,13 +69,13 @@

    Classes

    class SetSuggestedPrompts:
         client: WebClient
         channel_id: str
    -    thread_ts: str
    +    thread_ts: Optional[str]
     
         def __init__(
             self,
             client: WebClient,
             channel_id: str,
    -        thread_ts: str,
    +        thread_ts: Optional[str] = None,
         ):
             self.client = client
             self.channel_id = channel_id
    @@ -85,6 +85,7 @@ 

    Classes

    self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -95,7 +96,7 @@

    Classes

    return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -111,7 +112,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    diff --git a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html index 6c0385e57..f034fc677 100644 --- a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html @@ -48,7 +48,7 @@

    Classes

    class SetSuggestedPrompts -(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +(client: slack_sdk.web.client.WebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -58,13 +58,13 @@

    Classes

    class SetSuggestedPrompts:
         client: WebClient
         channel_id: str
    -    thread_ts: str
    +    thread_ts: Optional[str]
     
         def __init__(
             self,
             client: WebClient,
             channel_id: str,
    -        thread_ts: str,
    +        thread_ts: Optional[str] = None,
         ):
             self.client = client
             self.channel_id = channel_id
    @@ -74,6 +74,7 @@ 

    Classes

    self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -84,7 +85,7 @@

    Classes

    return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -100,7 +101,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    diff --git a/docs/reference/index.html b/docs/reference/index.html index 1c02a8aeb..ac1666851 100644 --- a/docs/reference/index.html +++ b/docs/reference/index.html @@ -36,7 +36,7 @@

    Package slack_bolt

    -

    A Python framework to build Slack apps in a flash with the latest platform features.Read the getting started guide and look at our code examples to learn how to build apps using Bolt.

    +

    A Python framework to build Slack apps in a flash with the latest platform features.Read the getting started guide and look at our code examples to learn how to build apps using Bolt.

    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. @@ -257,7 +258,7 @@

    Class variables

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. + Refer to https://docs.slack.dev/tools/bolt-python/creating-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. @@ -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, @@ -1585,7 +1578,7 @@

    Class variables

    if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) -

    Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details.

    +

    Refer to https://docs.slack.dev/tools/bolt-python/creating-an-app for details.

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

    Args

    @@ -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
    @@ -5843,6 +5876,106 @@

    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,
    +        icon_emoji: Optional[str] = None,
    +        icon_url: Optional[str] = None,
    +        username: 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,
    +                icon_emoji=icon_emoji,
    +                icon_url=icon_url,
    +                username=username,
    +                **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,
    +            icon_emoji=icon_emoji,
    +            icon_url=icon_url,
    +            username=username,
    +            **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) @@ -5871,6 +6004,9 @@

    Class variables

    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( @@ -5878,6 +6014,9 @@

    Class variables

    thread_ts=self.thread_ts, status=status, loading_messages=loading_messages, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, **kwargs, )
    @@ -5900,7 +6039,7 @@

    Class variables

    class SetSuggestedPrompts -(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +(client: slack_sdk.web.client.WebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -5910,13 +6049,13 @@

    Class variables

    class SetSuggestedPrompts:
         client: WebClient
         channel_id: str
    -    thread_ts: str
    +    thread_ts: Optional[str]
     
         def __init__(
             self,
             client: WebClient,
             channel_id: str,
    -        thread_ts: str,
    +        thread_ts: Optional[str] = None,
         ):
             self.client = client
             self.channel_id = channel_id
    @@ -5926,6 +6065,7 @@ 

    Class variables

    self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -5936,7 +6076,7 @@

    Class variables

    return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -5952,7 +6092,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    @@ -6110,6 +6250,7 @@

    Args

    response
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • @@ -6157,6 +6298,7 @@

    BoltC
  • respond
  • save_thread_context
  • say
  • +
  • say_stream
  • set_status
  • set_suggested_prompts
  • set_title
  • @@ -6262,6 +6404,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/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..8f7b1ba4f 100644 --- a/docs/reference/middleware/async_builtins.html +++ b/docs/reference/middleware/async_builtins.html @@ -46,6 +46,98 @@

      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 None:
      +            return await next()
      +        if req.context.channel_id is None:
      +            return await next()
      +
      +        if is_assistant_event(req.body):
      +            # TODO: eventually we might remove this assistant specific logic
      +            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["get_thread_context"] = assistant.get_thread_context
      +            req.context["save_thread_context"] = assistant.save_thread_context
      +
      +        if (
      +            is_im_message_event(req.body)
      +            or is_assistant_thread_started_event(req.body)
      +            or is_assistant_thread_context_changed_event(req.body)
      +            or is_app_home_opened_event(req.body, tab="messages")
      +        ):
      +            req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=req.context.thread_ts,
      +            )
      +
      +        # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +        thread_ts_or_ts = req.context.thread_ts or event.get("ts")
      +        if thread_ts_or_ts:
      +            req.context["set_status"] = AsyncSetStatus(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=thread_ts_or_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_or_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 +487,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..e2bbe7045 --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html @@ -0,0 +1,171 @@ + + + + + + +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 None:
      +            return await next()
      +        if req.context.channel_id is None:
      +            return await next()
      +
      +        if is_assistant_event(req.body):
      +            # TODO: eventually we might remove this assistant specific logic
      +            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["get_thread_context"] = assistant.get_thread_context
      +            req.context["save_thread_context"] = assistant.save_thread_context
      +
      +        if (
      +            is_im_message_event(req.body)
      +            or is_assistant_thread_started_event(req.body)
      +            or is_assistant_thread_context_changed_event(req.body)
      +            or is_app_home_opened_event(req.body, tab="messages")
      +        ):
      +            req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=req.context.thread_ts,
      +            )
      +
      +        # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +        thread_ts_or_ts = req.context.thread_ts or event.get("ts")
      +        if thread_ts_or_ts:
      +            req.context["set_status"] = AsyncSetStatus(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=thread_ts_or_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_or_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..e9d558fec --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html @@ -0,0 +1,165 @@ + + + + + + +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 None:
      +            return next()
      +        if req.context.channel_id is None:
      +            return next()
      +
      +        if is_assistant_event(req.body):
      +            # TODO: eventually we might remove this assistant specific logic
      +            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["get_thread_context"] = assistant.get_thread_context
      +            req.context["save_thread_context"] = assistant.save_thread_context
      +
      +        if (
      +            is_im_message_event(req.body)
      +            or is_assistant_thread_started_event(req.body)
      +            or is_assistant_thread_context_changed_event(req.body)
      +            or is_app_home_opened_event(req.body, tab="messages")
      +        ):
      +            req.context["set_suggested_prompts"] = SetSuggestedPrompts(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=req.context.thread_ts,
      +            )
      +
      +        # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +        thread_ts_or_ts = req.context.thread_ts or event.get("ts")
      +        if thread_ts_or_ts:
      +            req.context["set_status"] = SetStatus(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=thread_ts_or_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_or_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..38da4442e --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/index.html @@ -0,0 +1,182 @@ + + + + + + +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 None:
      +            return next()
      +        if req.context.channel_id is None:
      +            return next()
      +
      +        if is_assistant_event(req.body):
      +            # TODO: eventually we might remove this assistant specific logic
      +            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["get_thread_context"] = assistant.get_thread_context
      +            req.context["save_thread_context"] = assistant.save_thread_context
      +
      +        if (
      +            is_im_message_event(req.body)
      +            or is_assistant_thread_started_event(req.body)
      +            or is_assistant_thread_context_changed_event(req.body)
      +            or is_app_home_opened_event(req.body, tab="messages")
      +        ):
      +            req.context["set_suggested_prompts"] = SetSuggestedPrompts(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=req.context.thread_ts,
      +            )
      +
      +        # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +        thread_ts_or_ts = req.context.thread_ts or event.get("ts")
      +        if thread_ts_or_ts:
      +            req.context["set_status"] = SetStatus(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=thread_ts_or_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_or_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..153342bc1 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,92 @@

      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 None:
      +            return next()
      +        if req.context.channel_id is None:
      +            return next()
      +
      +        if is_assistant_event(req.body):
      +            # TODO: eventually we might remove this assistant specific logic
      +            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["get_thread_context"] = assistant.get_thread_context
      +            req.context["save_thread_context"] = assistant.save_thread_context
      +
      +        if (
      +            is_im_message_event(req.body)
      +            or is_assistant_thread_started_event(req.body)
      +            or is_assistant_thread_context_changed_event(req.body)
      +            or is_app_home_opened_event(req.body, tab="messages")
      +        ):
      +            req.context["set_suggested_prompts"] = SetSuggestedPrompts(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=req.context.thread_ts,
      +            )
      +
      +        # TODO: in the future we might want to introduce a "proper" extract_ts utility
      +        thread_ts_or_ts = req.context.thread_ts or event.get("ts")
      +        if thread_ts_or_ts:
      +            req.context["set_status"] = SetStatus(
      +                client=req.context.client,
      +                channel_id=req.context.channel_id,
      +                thread_ts=thread_ts_or_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_or_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 +475,7 @@

      Inherited members

      Subclasses

      • Assistant
      • +
      • AttachingConversationKwargs
      • AttachingFunctionToken
      • Authorization
      • CustomMiddleware
      • @@ -645,9 +736,17 @@

        Inherited members

        signing_secret: The signing secret base_logger: The base logger """ - self.verifier = SignatureVerifier(signing_secret=signing_secret) + self._signing_secret = signing_secret + self._verifier: Optional[SignatureVerifier] = None self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) + @property + def verifier(self) -> SignatureVerifier: + # Defer initialization to avoid errors during start up + if self._verifier is None: + self._verifier = SignatureVerifier(signing_secret=self._signing_secret) + return self._verifier + def process( self, *, @@ -674,7 +773,7 @@

        Inherited members

        @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: @@ -704,6 +803,24 @@

        Subclasses

        +

        Instance variables

        +
        +
        prop verifier : slack_sdk.signature.SignatureVerifier
        +
        +
        + +Expand source code + +
        @property
        +def verifier(self) -> SignatureVerifier:
        +    # Defer initialization to avoid errors during start up
        +    if self._verifier is None:
        +        self._verifier = SignatureVerifier(signing_secret=self._signing_secret)
        +    return self._verifier
        +
        +
        +
        +

        Inherited members

  • Ancestors

    diff --git a/docs/reference/middleware/request_verification/index.html b/docs/reference/middleware/request_verification/index.html index 5dfd6ed82..50a8676b5 100644 --- a/docs/reference/middleware/request_verification/index.html +++ b/docs/reference/middleware/request_verification/index.html @@ -77,9 +77,17 @@

    Classes

    signing_secret: The signing secret base_logger: The base logger """ - self.verifier = SignatureVerifier(signing_secret=signing_secret) + self._signing_secret = signing_secret + self._verifier: Optional[SignatureVerifier] = None self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) + @property + def verifier(self) -> SignatureVerifier: + # Defer initialization to avoid errors during start up + if self._verifier is None: + self._verifier = SignatureVerifier(signing_secret=self._signing_secret) + return self._verifier + def process( self, *, @@ -106,7 +114,7 @@

    Classes

    @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: @@ -136,6 +144,24 @@

    Subclasses

    +

    Instance variables

    +
    +
    prop verifier : slack_sdk.signature.SignatureVerifier
    +
    +
    + +Expand source code + +
    @property
    +def verifier(self) -> SignatureVerifier:
    +    # Defer initialization to avoid errors during start up
    +    if self._verifier is None:
    +        self._verifier = SignatureVerifier(signing_secret=self._signing_secret)
    +    return self._verifier
    +
    +
    +
    +

    Inherited members

    • Middleware: @@ -169,6 +195,9 @@

      Inherited members

    • diff --git a/docs/reference/middleware/request_verification/request_verification.html b/docs/reference/middleware/request_verification/request_verification.html index 99134110a..4ee2ed1b1 100644 --- a/docs/reference/middleware/request_verification/request_verification.html +++ b/docs/reference/middleware/request_verification/request_verification.html @@ -66,9 +66,17 @@

      Classes

      signing_secret: The signing secret base_logger: The base logger """ - self.verifier = SignatureVerifier(signing_secret=signing_secret) + self._signing_secret = signing_secret + self._verifier: Optional[SignatureVerifier] = None self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) + @property + def verifier(self) -> SignatureVerifier: + # Defer initialization to avoid errors during start up + if self._verifier is None: + self._verifier = SignatureVerifier(signing_secret=self._signing_secret) + return self._verifier + def process( self, *, @@ -95,7 +103,7 @@

      Classes

      @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: @@ -125,6 +133,24 @@

      Subclasses

      +

      Instance variables

      +
      +
      prop verifier : slack_sdk.signature.SignatureVerifier
      +
      +
      + +Expand source code + +
      @property
      +def verifier(self) -> SignatureVerifier:
      +    # Defer initialization to avoid errors during start up
      +    if self._verifier is None:
      +        self._verifier = SignatureVerifier(signing_secret=self._signing_secret)
      +    return self._verifier
      +
      +
      +
      +

      Inherited members

      • Middleware: @@ -152,6 +178,9 @@

        Inherited members

      • 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/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/request/internals.html b/docs/reference/request/internals.html index bc13932ec..d25880135 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: - return payload["user"]["team_id"] - if payload.get("view") is not None: - return payload.get("view", {})["team_id"] + if isinstance(payload.get("user"), dict): + return payload["user"].get("team_id") + if isinstance(payload.get("view"), dict): + return payload["view"].get("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/docs/reference/request/payload_utils.html b/docs/reference/request/payload_utils.html index 4fe75fd81..b583c3a51 100644 --- a/docs/reference/request/payload_utils.html +++ b/docs/reference/request/payload_utils.html @@ -63,6 +63,39 @@

        Functions

    +
    +def is_any_im_message_event(body: Dict[str, Any]) ‑> bool +
    +
    +
    + +Expand source code + +
    def is_any_im_message_event(body: Dict[str, Any]) -> bool:
    +    if is_message_event(body):
    +        # Any message event with no subtype or any subtype (message_changed, message_deleted, etc.)
    +        return body["event"].get("channel_type") == "im"
    +    return False
    +
    +
    +
    +
    +def is_app_home_opened_event(body: Dict[str, Any], tab: str | None = None) ‑> bool +
    +
    +
    + +Expand source code + +
    def is_app_home_opened_event(body: Dict[str, Any], tab: Optional[str] = None) -> bool:
    +    if is_event(body) and body["event"]["type"] == "app_home_opened":
    +        if tab is not None:
    +            return body["event"].get("tab") == tab
    +        return True
    +    return False
    +
    +
    +
    def is_assistant_event(body: Dict[str, Any]) ‑> bool
    @@ -159,10 +192,9 @@

    Functions

    Expand source code
    def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
    -    if is_event(body):
    +    if is_any_im_message_event(body):
             return (
    -            is_message_event_in_assistant_thread(body)
    -            and body["event"].get("subtype") is None
    +            body["event"].get("subtype") is None
                 and body["event"].get("thread_ts") is not None
                 and body["event"].get("bot_id") is not None
             )
    @@ -248,17 +280,32 @@ 

    Functions

    -
    -def is_message_event_in_assistant_thread(body: Dict[str, Any]) ‑> bool +
    +def is_im_message_event(body: Dict[str, Any]) ‑> bool +
    +
    +
    + +Expand source code + +
    def is_im_message_event(body: Dict[str, Any]) -> bool:
    +    if is_any_im_message_event(body):
    +        return body["event"].get("subtype") in (None, "file_share")
    +    return False
    +
    +
    +
    +
    +def is_message_event(body: Dict[str, Any]) ‑> bool
    Expand source code -
    def is_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
    +
    def is_message_event(body: Dict[str, Any]) -> bool:
         if is_event(body):
    -        return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im"
    +        return body["event"]["type"] == "message"
         return False
    @@ -299,14 +346,10 @@

    Functions

    def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
         # message_changed, message_deleted etc.
    -    if is_event(body):
    -        return (
    -            is_message_event_in_assistant_thread(body)
    -            and not is_user_message_event_in_assistant_thread(body)
    -            and (
    -                _is_other_message_sub_event(body["event"].get("message"))
    -                or _is_other_message_sub_event(body["event"].get("previous_message"))
    -            )
    +    if is_any_im_message_event(body):
    +        return not is_user_message_event_in_assistant_thread(body) and (
    +            _is_other_message_sub_event(body["event"].get("message"))
    +            or _is_other_message_sub_event(body["event"].get("previous_message"))
             )
         return False
    @@ -347,13 +390,8 @@

    Functions

    Expand source code
    def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
    -    if is_event(body):
    -        return (
    -            is_message_event_in_assistant_thread(body)
    -            and body["event"].get("subtype") in (None, "file_share")
    -            and body["event"].get("thread_ts") is not None
    -            and body["event"].get("bot_id") is None
    -        )
    +    if is_im_message_event(body):
    +        return body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is None
         return False
    @@ -491,7 +529,7 @@

    Functions

    Expand source code
    def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    -    if is_event(body) and body["event"]["type"] == "message":
    +    if is_message_event(body):
             return to_event(body)
         return None
    @@ -582,6 +620,8 @@

    Functions

  • Functions

    • is_action
    • +
    • is_any_im_message_event
    • +
    • is_app_home_opened_event
    • is_assistant_event
    • is_assistant_thread_context_changed_event
    • is_assistant_thread_started_event
    • @@ -595,7 +635,8 @@

      Functions

    • is_event
    • is_function
    • is_global_shortcut
    • -
    • is_message_event_in_assistant_thread
    • +
    • is_im_message_event
    • +
    • is_message_event
    • is_message_shortcut
    • is_options
    • is_other_message_sub_event_in_assistant_thread
    • diff --git a/examples/aws_lambda/README.md b/examples/aws_lambda/README.md index 49a8f7da2..2fc34b7cf 100644 --- a/examples/aws_lambda/README.md +++ b/examples/aws_lambda/README.md @@ -33,15 +33,15 @@ Instructions on how to set up and deploy each example are provided below. - Optionally enter a description for the role, such as "Bolt Python basic role" 3. Ensure you have created an app on api.slack.com/apps as per the - [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. + [Building an App](https://docs.slack.dev/tools/bolt-python/creating-an-app) guide. Ensure you have installed it to a workspace. 4. Ensure you have exported your Slack Bot Token and Slack Signing Secret for your apps as the environment variables `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET`, respectively, as per the - [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. + [Building an App](https://docs.slack.dev/tools/bolt-python/creating-an-app) guide. 5. You may want to create a dedicated virtual environment for this example app, as per the "Setting up your project" section of the - [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. + [Building an App](https://docs.slack.dev/tools/bolt-python/creating-an-app) guide. 6. Let's deploy the Lambda! Run `./deploy_lazy.sh`. By default it deploys to the us-east-1 region in AWS - you can change this at the top of `lazy_aws_lambda_config.yaml` if you wish. 7. Load up AWS Lambda inside the AWS Console - make sure you are in the correct @@ -150,7 +150,7 @@ Let’s create a user role that will use the custom policy we created as well as 3. "Create Role" ### Create Slack App and Load your Lambda to AWS -Ensure you have created an app on [api.slack.com/apps](https://api.slack.com/apps) as per the [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. You do not need to ensure you have installed it to a workspace, as the OAuth flow will provide your app the ability to be installed by anyone. +Ensure you have created an app on [api.slack.com/apps](https://api.slack.com/apps) as per the [Building an App](https://docs.slack.dev/tools/bolt-python/creating-an-app) guide. You do not need to ensure you have installed it to a workspace, as the OAuth flow will provide your app the ability to be installed by anyone. 1. Remember those S3 buckets we made? You will need the names of these buckets again in the next step. 2. You need many environment variables exported! Specifically the following from api.slack.com/apps diff --git a/examples/django/README.md b/examples/django/README.md index ca0460fd1..cb29a822b 100644 --- a/examples/django/README.md +++ b/examples/django/README.md @@ -4,7 +4,7 @@ This example demonstrates how you can use Bolt for Python in your Django applica ### `simple_app` - Single-workspace App Example -If you want to run a simple app like the one you've tried in the [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide, this is the right one for you. By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. +If you want to run a simple app like the one you've tried in the [Building an App](https://docs.slack.dev/tools/bolt-python/creating-an-app) guide, this is the right one for you. By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. To run this app, all you need to do are: @@ -31,7 +31,7 @@ python manage.py migrate python manage.py runserver 0.0.0.0:3000 ``` -As you did at [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, +As you did at [Building an App](https://docs.slack.dev/tools/bolt-python/creating-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, * Go back to the Slack app configuration page * Go to "Event Subscriptions" @@ -73,7 +73,7 @@ python manage.py migrate python manage.py runserver 0.0.0.0:3000 ``` -As you did at [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, +As you did at [Building an App](https://docs.slack.dev/tools/bolt-python/creating-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, * Go back to the Slack app configuration page * Go to "Event Subscriptions" diff --git a/examples/getting_started/README.md b/examples/getting_started/README.md index 5d3c2f61d..48f9d4095 100644 --- a/examples/getting_started/README.md +++ b/examples/getting_started/README.md @@ -42,6 +42,6 @@ ngrok http 3000 python3 app.py ``` -[1]: https://docs.slack.dev/tools/bolt-python/building-an-app +[1]: https://docs.slack.dev/tools/bolt-python/creating-an-app [2]: https://docs.slack.dev/tools/bolt-python/ -[3]: https://docs.slack.dev/tools/bolt-python/building-an-app#setting-up-events +[3]: https://docs.slack.dev/tools/bolt-python/creating-an-app#setting-up-events diff --git a/pyproject.toml b/pyproject.toml index a5c12548b..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*"] @@ -46,11 +47,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/requirements/adapter.txt b/requirements/adapter.txt deleted file mode 100644 index c19c7713b..000000000 --- a/requirements/adapter.txt +++ /dev/null @@ -1,28 +0,0 @@ -# pip install -r requirements/adapter.txt -# NOTE: any of async ones requires pip install -r requirements/async.txt too -# used only under slack_bolt/adapter -boto3<=2 -bottle>=0.12,<1 -chalice>=1.28,<2; -cheroot<12 -CherryPy>=18,<19 -Django>=3,<6 -falcon>=2,<5; python_version<"3.11" -falcon>=3.1.1,<5; python_version>="3.11" -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 -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" - -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 diff --git a/requirements/adapter_dev.txt b/requirements/adapter_dev.txt new file mode 100644 index 000000000..34a96a911 --- /dev/null +++ b/requirements/adapter_dev.txt @@ -0,0 +1,79 @@ +# pip install -r requirements/adapter_dev.txt +# Note: any of the async ones requires pip install -r requirements/async_dev.txt too +# Note: used only under slack_bolt/adapter + +# boto3 +boto3<=2 + +# bottle +bottle>=0.13.4,<1 + +# chalice +chalice>=1.28,<1.31; python_version < "3.9" +chalice>=1.32.0,<1.33; python_version >= "3.9" and python_version < "3.10" +chalice>=1.33.0,<2; python_version >= "3.10" + +# cheroot +cheroot<12 + +# CherryPy +CherryPy>=18.10.0,<19 + +# Django +# Note: Django 5.x requires Python >=3.10 and 4.x requires >=3.8, so 3.8/3.9 stay on the 4.2 LTS line and 3.7 stays on the 3.2 line. +Django>=3.2,<4; python_version < "3.8" +Django>=4.2.30,<5; python_version >= "3.8" and python_version < "3.10" +Django>=5.2.15,<6; python_version >= "3.10" + +# falcon +# Note: falcon 4.2.0 requires Python >=3.9; 3.7/3.8 stay on the older pinned release. +falcon>=2,<4; python_version < "3.9" +falcon>=4.2.0,<5; python_version >= "3.9" + +# fastapi +fastapi>=0.70.0,<1; python_version < "3.9" +fastapi>=0.128.8,<0.129.0; python_version >= "3.9" and python_version < "3.10" +fastapi>=0.141.1,<1; python_version >= "3.10" + +# Flask +# Note: Flask 3.1.3 requires Python >=3.9; 3.7/3.8 stay on the older pinned release. +Flask>=1,<4; python_version < "3.9" +Flask>=3.1.3,<4; python_version >= "3.9" + +# Werkzeug +# Note: Werkzeug 3.1.8 requires Python >=3.9; 3.7/3.8 stay on the older pinned release. +Werkzeug>=2,<3; python_version < "3.9" +Werkzeug>=3.1.8,<4; python_version >= "3.9" + +# pyramid +# Note: pyramid 2.1 requires Python >=3.10; 3.7/3.8/3.9 stay on the last 2.0.x release. +pyramid>=1,<2.1; python_version < "3.10" +pyramid>=2.1,<3; python_version >= "3.10" + +# setuptools +# Note: Pyramid depends on pkg_resources (deprecated in setuptools 67.5.0, removed in 82+). See: https://github.com/Pylons/pyramid/issues/3731 +setuptools<82 + +# sanic +# Note: Sanic pulls in tracerite via a wildcard version, so tracerite is co-pinned here. +# Note: tracerite > 1.1.2 is incompatible with Python <= 3.8 and ships no requires_python, so an explicit ceiling is required. +# Note: sanic 25.12 requires Python >=3.10; 3.9 stays on the older 25.x line. +tracerite<1.1.2; python_version < "3.9" +sanic>=21,<24; python_version < "3.9" +sanic>=25.3.0,<25.12.0; python_version >= "3.9" and python_version < "3.10" +sanic>=25.12.1,<26; python_version >= "3.10" + +# starlette +# Note: starlette 0.49.3 requires Python >=3.9; 3.7/3.8 stay on the older pinned release. +starlette>=0.19.1,<0.45; python_version < "3.9" +starlette>=0.49.3,<1; python_version >= "3.9" + +# tornado +# Note: tornado 6.5.6 requires Python >=3.9; 3.7/3.8 stay on the older pinned release. +tornado>=6.2,<7; python_version < "3.9" +tornado>=6.5.6,<7; python_version >= "3.9" + +# websocket_client +# Note: Socket Mode 3rd party implementation +websocket_client>=1.2.3,<1.9; python_version < "3.9" +websocket_client>=1.9.0,<2; python_version >= "3.9" diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt deleted file mode 100644 index c497a1f3f..000000000 --- a/requirements/adapter_testing.txt +++ /dev/null @@ -1,5 +0,0 @@ -# pip install -r requirements/adapter_testing.txt -moto>=3,<6 # For AWS tests -docker>=5,<8 # Used by moto -boddle>=0.2,<0.3 # For Bottle app tests -sanic-testing>=0.7 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..32dff305b --- /dev/null +++ b/requirements/async_dev.txt @@ -0,0 +1,9 @@ +# pip install -r requirements/async_dev.txt + +# aiohttp +# Note: aiohttp 3.13.5 requires Python >=3.9; 3.7/3.8 stay on the older pinned release. +aiohttp>=3,<4; python_version < "3.9" +aiohttp>=3.13.5,<4; python_version >= "3.9" + +# websockets +websockets<17 diff --git a/requirements/dev_tools.txt b/requirements/dev_tools.txt new file mode 100644 index 000000000..59e4cdd21 --- /dev/null +++ b/requirements/dev_tools.txt @@ -0,0 +1,10 @@ +# pip install -r requirements/dev_tools.txt + +# mypy +mypy==2.3.0 + +# flake8 +flake8==7.3.0 + +# black +black==26.3.1 diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 000000000..af895692b --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,8 @@ +# pip install -r requirements/test.txt + +# pytest +pytest<9.2 + +# pytest-cov +# Note: only needed to evaluate coverage on the latest supported python version +pytest-cov>=7.1.0,<8; python_version >= "3.14" diff --git a/requirements/test_adapter.txt b/requirements/test_adapter.txt new file mode 100644 index 000000000..702856aac --- /dev/null +++ b/requirements/test_adapter.txt @@ -0,0 +1,16 @@ +# pip install -r requirements/test_adapter.txt + +# moto +# Note: for AWS tests +# Note: moto drops old Pythons across the 5.x line — 5.0.0 requires >=3.8, 5.1.0 >=3.9, 5.2.0 >=3.10; older interpreters stay on the last compatible release. +moto>=3,<5; python_version < "3.8" +moto>=3,<5.1; python_version >= "3.8" and python_version < "3.9" +moto>=3,<5.2; python_version >= "3.9" and python_version < "3.10" +moto>=5.2.2,<6; python_version >= "3.10" + +# boddle +# Note: for Bottle app tests +boddle>=0.2.9,<0.3 + +# sanic-testing +sanic-testing>=24.6.0 diff --git a/requirements/test_async.txt b/requirements/test_async.txt new file mode 100644 index 000000000..5dc51a201 --- /dev/null +++ b/requirements/test_async.txt @@ -0,0 +1,11 @@ +# pip install -r requirements/test_async.txt +-r test.txt +-r async_dev.txt + +# asgiref +# Note: asgiref 3.11.1 requires Python >=3.9; 3.7/3.8 stay on the older pinned release. +asgiref>=3.7.2,<3.8; python_version < "3.9" +asgiref>=3.11.1,<4; python_version >= "3.9" + +# pytest-asyncio +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/requirements/tools.txt b/requirements/tools.txt deleted file mode 100644 index dd13bd614..000000000 --- a/requirements/tools.txt +++ /dev/null @@ -1,3 +0,0 @@ -mypy==1.19.1 -flake8==7.3.0 -black==26.3.1 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/__init__.py b/slack_bolt/__init__.py index 4e43252fd..e3664814b 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -1,5 +1,5 @@ """ -A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://docs.slack.dev/tools/bolt-python/building-an-app) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. +A Python framework to build Slack apps in a flash with the latest platform features.Read the [getting started guide](https://docs.slack.dev/tools/bolt-python/creating-an-app) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. * Website: https://docs.slack.dev/tools/bolt-python/ * GitHub repository: https://github.com/slackapi/bolt-python @@ -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 @@ -21,7 +22,6 @@ from .response import BoltResponse # AI Agents & Assistants -from .agent import BoltAgent from .middleware.assistant.assistant import ( Assistant, ) @@ -42,12 +42,12 @@ "Fail", "Respond", "Say", + "SayStream", "Args", "Listener", "CustomListenerMatcher", "BoltRequest", "BoltResponse", - "BoltAgent", "Assistant", "AssistantThreadContext", "AssistantThreadContextStore", 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/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/app/app.py b/slack_bolt/app/app.py index 566eb82d7..e20649902 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. @@ -156,7 +156,7 @@ def message_hello(message, say): if __name__ == "__main__": app.start(port=int(os.environ.get("PORT", 3000))) - Refer to https://docs.slack.dev/tools/bolt-python/building-an-app for details. + Refer to https://docs.slack.dev/tools/bolt-python/creating-an-app for details. If you would like to build an OAuth app for enabling the app to run with multiple workspaces, refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. @@ -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/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/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/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index 53500efdb..e9614fd8d 100644 --- a/slack_bolt/context/assistant/assistant_utilities.py +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -10,8 +10,6 @@ from .internals import has_channel_id_and_thread_ts from ..get_thread_context.get_thread_context import GetThreadContext from ..save_thread_context import SaveThreadContext -from ..set_status import SetStatus -from ..set_suggested_prompts import SetSuggestedPrompts from ..set_title import SetTitle @@ -46,21 +44,10 @@ def __init__( # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> SetStatus: - return SetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> SetTitle: return SetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> SetSuggestedPrompts: - return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> Say: def build_metadata() -> Optional[dict]: diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py index 5a7324e99..6acf531ce 100644 --- a/slack_bolt/context/assistant/async_assistant_utilities.py +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -13,8 +13,6 @@ from .internals import has_channel_id_and_thread_ts from ..get_thread_context.async_get_thread_context import AsyncGetThreadContext from ..save_thread_context.async_save_thread_context import AsyncSaveThreadContext -from ..set_status.async_set_status import AsyncSetStatus -from ..set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from ..set_title.async_set_title import AsyncSetTitle @@ -49,21 +47,10 @@ def __init__( # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> AsyncSetStatus: - return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> AsyncSetTitle: return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts: - return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> AsyncSay: return AsyncSay( diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 631f74a82..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 @@ -10,11 +10,15 @@ 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 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.""" @@ -41,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"] @@ -203,6 +207,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..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 @@ -10,11 +10,15 @@ 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 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.""" @@ -42,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"] @@ -204,6 +208,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/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/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..df9b362e2 --- /dev/null +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -0,0 +1,71 @@ +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_chat_stream import AsyncChatStream + + +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, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: 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, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, + **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, + 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 new file mode 100644 index 000000000..15bdcc110 --- /dev/null +++ b/slack_bolt/context/say_stream/say_stream.py @@ -0,0 +1,71 @@ +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.web.chat_stream import ChatStream + + +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, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: 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, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, + **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, + 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/context/set_suggested_prompts/async_set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py index 2079b6448..68b41858b 100644 --- a/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py @@ -7,13 +7,13 @@ class AsyncSetSuggestedPrompts: client: AsyncWebClient channel_id: str - thread_ts: str + thread_ts: Optional[str] def __init__( self, client: AsyncWebClient, channel_id: str, - thread_ts: str, + thread_ts: Optional[str] = None, ): self.client = client self.channel_id = channel_id @@ -23,6 +23,7 @@ async def __call__( self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -33,7 +34,7 @@ async def __call__( return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, ) diff --git a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py index 21ff815e1..349481df4 100644 --- a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -7,13 +7,13 @@ class SetSuggestedPrompts: client: WebClient channel_id: str - thread_ts: str + thread_ts: Optional[str] def __init__( self, client: WebClient, channel_id: str, - thread_ts: str, + thread_ts: Optional[str] = None, ): self.client = client self.channel_id = channel_id @@ -23,6 +23,7 @@ def __call__( self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -33,7 +34,7 @@ def __call__( return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, ) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 113e39c08..f2b4099d6 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -8,9 +8,9 @@ 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 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 @@ -103,8 +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""" + 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""" @@ -138,7 +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, + 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 @@ -172,7 +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.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..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 @@ -10,6 +9,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 @@ -102,8 +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""" + 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""" @@ -137,7 +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, + say_stream: Optional[AsyncSayStream] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa ): @@ -168,7 +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.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..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, @@ -60,6 +58,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 @@ -85,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 5cd410a07..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, @@ -59,6 +57,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 @@ -84,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/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/__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/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/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_agent_kwargs/async_attaching_agent_kwargs.py b/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py deleted file mode 100644 index 0b43c21ce..000000000 --- a/slack_bolt/middleware/attaching_agent_kwargs/async_attaching_agent_kwargs.py +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 4963ea67d..000000000 --- a/slack_bolt/middleware/attaching_agent_kwargs/attaching_agent_kwargs.py +++ /dev/null @@ -1,33 +0,0 @@ -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/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_conversation_kwargs/async_attaching_conversation_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py new file mode 100644 index 000000000..ab69f5768 --- /dev/null +++ b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py @@ -0,0 +1,80 @@ +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.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.middleware.async_middleware import AsyncMiddleware +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import ( + is_app_home_opened_event, + is_assistant_event, + is_assistant_thread_context_changed_event, + is_assistant_thread_started_event, + is_im_message_event, + to_event, +) +from slack_bolt.response import BoltResponse + + +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 None: + return await next() + if req.context.channel_id is None: + return await next() + + if is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + 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["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = AsyncSetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_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_or_ts, + ) + return await next() diff --git a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py new file mode 100644 index 000000000..2d6ce7b01 --- /dev/null +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -0,0 +1,74 @@ +from typing import Optional, Callable + +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.context.set_suggested_prompts.set_suggested_prompts import SetSuggestedPrompts +from slack_bolt.middleware import Middleware +from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities +from slack_bolt.request.payload_utils import ( + is_app_home_opened_event, + is_assistant_event, + is_assistant_thread_context_changed_event, + is_assistant_thread_started_event, + is_im_message_event, + to_event, +) +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse + + +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 None: + return next() + if req.context.channel_id is None: + return next() + + if is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + 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["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_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_or_ts, + ) + return next() 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/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 2cf7e361e..af505bc84 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Callable, Dict, Any, Optional +from typing import Any, Callable, Dict, Optional from slack_sdk.signature import SignatureVerifier @@ -20,9 +20,17 @@ def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None): signing_secret: The signing secret base_logger: The base logger """ - self.verifier = SignatureVerifier(signing_secret=signing_secret) + self._signing_secret = signing_secret + self._verifier: Optional[SignatureVerifier] = None self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) + @property + def verifier(self) -> SignatureVerifier: + # Defer initialization to avoid errors during start up + if self._verifier is None: + self._verifier = SignatureVerifier(signing_secret=self._signing_secret) + return self._verifier + def process( self, *, @@ -49,7 +57,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/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. diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 466f5daaf..e0863a713 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: - return payload["user"]["team_id"] - if payload.get("view") is not None: - return payload.get("view", {})["team_id"] + if isinstance(payload.get("user"), dict): + return payload["user"].get("team_id") + if isinstance(payload.get("view"), dict): + return payload["view"].get("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/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 1ebf70d4f..b74238461 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -14,7 +14,7 @@ def to_event(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_event(body) and body["event"]["type"] == "message": + if is_message_event(body): return to_event(body) return None @@ -31,6 +31,25 @@ def is_workflow_step_execute(body: Dict[str, Any]) -> bool: return is_event(body) and body["event"]["type"] == "workflow_step_execute" and "workflow_step" in body["event"] +def is_message_event(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "message" + return False + + +def is_any_im_message_event(body: Dict[str, Any]) -> bool: + if is_message_event(body): + # Any message event with no subtype or any subtype (message_changed, message_deleted, etc.) + return body["event"].get("channel_type") == "im" + return False + + +def is_im_message_event(body: Dict[str, Any]) -> bool: + if is_any_im_message_event(body): + return body["event"].get("subtype") in (None, "file_share") + return False + + def is_assistant_event(body: Dict[str, Any]) -> bool: return is_event(body) and ( is_assistant_thread_started_event(body) @@ -52,28 +71,24 @@ def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: return False -def is_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): - return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im" +def is_app_home_opened_event(body: Dict[str, Any], tab: Optional[str] = None) -> bool: + if is_event(body) and body["event"]["type"] == "app_home_opened": + if tab is not None: + return body["event"].get("tab") == tab + return True return False def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): - return ( - is_message_event_in_assistant_thread(body) - and body["event"].get("subtype") in (None, "file_share") - and body["event"].get("thread_ts") is not None - and body["event"].get("bot_id") is None - ) + if is_im_message_event(body): + return body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is None return False def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): + if is_any_im_message_event(body): return ( - is_message_event_in_assistant_thread(body) - and body["event"].get("subtype") is None + body["event"].get("subtype") is None and body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is not None ) @@ -82,14 +97,10 @@ def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) -> bool: # message_changed, message_deleted etc. - if is_event(body): - return ( - is_message_event_in_assistant_thread(body) - and not is_user_message_event_in_assistant_thread(body) - and ( - _is_other_message_sub_event(body["event"].get("message")) - or _is_other_message_sub_event(body["event"].get("previous_message")) - ) + if is_any_im_message_event(body): + return not is_user_message_event_in_assistant_thread(body) and ( + _is_other_message_sub_event(body["event"].get("message")) + or _is_other_message_sub_event(body["event"].get("previous_message")) ) return False diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 9b1349aea..2c08c0adb 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.30.0" 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/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/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/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/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/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/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/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_app.py b/tests/scenario_tests/test_app.py index 9bbad6d9a..5cdff39bf 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 @@ -96,6 +96,13 @@ def test_token_verification_enabled_False(self): assert self.received_requests.get("/auth.test") is None + def test_socket_mode_app_without_signing_secret(self): + app = App( + client=self.web_client, + token_verification_enabled=False, + ) + assert app is not None + # -------------------------- # multi teams auth # -------------------------- @@ -236,12 +243,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 +264,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_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/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py index 5bc270d86..c95296154 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,83 @@ 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=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 - 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 +203,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: @@ -343,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", @@ -395,7 +409,7 @@ def build_payload(event: dict) -> dict: "user_profile": {}, "thread_ts": "1726133698.626339", "parent_user_id": "W222", - "channel": "D111", + "channel": "C111", "event_ts": "1726133700.887259", "channel_type": "channel", } @@ -430,7 +444,7 @@ def build_payload(event: dict) -> dict: "reply_users": ["U222", "W111"], "is_locked": False, }, - "channel": "D111", + "channel": "C111", "hidden": True, "ts": "1726133701.028300", "event_ts": "1726133701.028300", diff --git a/tests/scenario_tests/test_events_assistant_without_middleware.py b/tests/scenario_tests/test_events_assistant_without_middleware.py index 36d86c43a..18072c05e 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( @@ -180,21 +177,21 @@ 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 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( @@ -208,21 +205,21 @@ 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 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( @@ -236,22 +233,22 @@ 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 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) + def test_assistant_events_conversation_kwargs_disabled(self): + app = App(client=self.web_client, attaching_conversation_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_ignore_self.py b/tests/scenario_tests/test_events_ignore_self.py index db7d07ea8..3c66f3278 100644 --- a/tests/scenario_tests/test_events_ignore_self.py +++ b/tests/scenario_tests/test_events_ignore_self.py @@ -100,6 +100,7 @@ def handle_app_mention(say): "type": "message", "channel": "C111", "ts": "1599529504.000400", + "channel_type": "channel", }, "reaction": "heart_eyes", "item_user": "W111", 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..e0ab66aab --- /dev/null +++ b/tests/scenario_tests/test_events_say_stream.py @@ -0,0 +1,224 @@ +import json +from threading import Event +from urllib.parse import quote + +from slack_sdk.web import WebClient + +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 +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 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) + listener_called = Event() + + @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 + listener_called.set() + + request = BoltRequest(body=app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_say_stream_with_org_level_install(self): + app = App(client=self.web_client) + listener_called = Event() + + @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" + listener_called.set() + + request = BoltRequest(body=org_app_mention_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_say_stream_injected_for_threaded_message(self): + app = App(client=self.web_client) + listener_called = Event() + + @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 + listener_called.set() + + request = BoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_say_stream_in_user_message(self): + app = App(client=self.web_client) + listener_called = Event() + + @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 + listener_called.set() + + request = BoltRequest(body=user_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_say_stream_in_bot_message(self): + app = App(client=self.web_client) + listener_called = Event() + + @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 + listener_called.set() + + request = BoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + 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() + listener_called = Event() + + @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 + 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 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() + listener_called = Event() + + @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 + 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 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) + 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 + listener_called.set() + + request = BoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + +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/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/test_function.py b/tests/scenario_tests/test_function.py index 5a4fc2685..1e5e1c423 100644 --- a/tests/scenario_tests/test_function.py +++ b/tests/scenario_tests/test_function.py @@ -7,6 +7,7 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient +import slack_bolt.listener.thread_runner as runner_module from slack_bolt.app import App from slack_bolt.request import BoltRequest from tests.mock_web_api_server import ( @@ -53,9 +54,9 @@ def build_request_from_body(self, message_body: dict) -> BoltRequest: timestamp, body = str(int(time.time())), json.dumps(message_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) - def setup_time_mocks(self, *, monkeypatch: pytest.MonkeyPatch, time_mock: Mock, sleep_mock: Mock): - monkeypatch.setattr(time, "time", time_mock) - monkeypatch.setattr(time, "sleep", sleep_mock) + def setup_time_mocks(self, *, monkeypatch: pytest.MonkeyPatch, time_mock, sleep_mock): + monkeypatch.setattr(runner_module.time, "time", time_mock) + monkeypatch.setattr(runner_module.time, "sleep", sleep_mock) def test_valid_callback_id_success(self): app = App( @@ -138,10 +139,20 @@ def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkeypatch) app.function("reverse", auto_acknowledge=False)(just_no_ack) request = self.build_request_from_body(function_body) + + elapsed_seconds = 0 + + def fake_time(): + return float(elapsed_seconds) + + def fake_sleep(duration): + nonlocal elapsed_seconds + elapsed_seconds += 1 + self.setup_time_mocks( monkeypatch=monkeypatch, - time_mock=Mock(side_effect=[current_time for current_time in range(100)]), - sleep_mock=Mock(), + time_mock=fake_time, + sleep_mock=Mock(side_effect=fake_sleep), ) response = app.dispatch(request) @@ -158,20 +169,29 @@ def test_function_handler_timeout(self, monkeypatch): app.function("reverse", auto_acknowledge=False, ack_timeout=timeout)(just_no_ack) request = self.build_request_from_body(function_body) - sleep_mock = Mock() + elapsed_seconds = 0 + + def fake_time(): + return float(elapsed_seconds) + + def fake_sleep(duration): + nonlocal elapsed_seconds + elapsed_seconds += 1 + self.setup_time_mocks( monkeypatch=monkeypatch, - time_mock=Mock(side_effect=[current_time for current_time in range(100)]), - sleep_mock=sleep_mock, + time_mock=fake_time, + sleep_mock=Mock(side_effect=fake_sleep), ) response = app.dispatch(request) assert response.status == 404 assert_auth_test_count(self, 1) - assert ( - sleep_mock.call_count == timeout - ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + assert elapsed_seconds == timeout + 1, ( + f"Expected handler to time out after {timeout + 1} time.sleep calls, " + f"but it was called {elapsed_seconds} times" + ) def test_warning_when_timeout_improperly_set(self, caplog): app = App( 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/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_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_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/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index 87b337536..9e1176c74 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,85 @@ 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=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 - 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 + app = AsyncApp(client=self.web_client, attaching_conversation_kwargs_enabled=False) + listener_called = asyncio.Event() @app.event("assistant_thread_started") async def start_thread(context: AsyncBoltContext): @@ -234,27 +226,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 +248,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 +266,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 +275,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 +313,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: @@ -426,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", @@ -478,7 +483,7 @@ def build_payload(event: dict) -> dict: "user_profile": {}, "thread_ts": "1726133698.626339", "parent_user_id": "W222", - "channel": "D111", + "channel": "C111", "event_ts": "1726133700.887259", "channel_type": "channel", } @@ -513,7 +518,7 @@ def build_payload(event: dict) -> dict: "reply_users": ["U222", "W111"], "is_locked": False, }, - "channel": "D111", + "channel": "C111", "hidden": True, "ts": "1726133701.028300", "event_ts": "1726133701.028300", 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..d72b09b04 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( @@ -197,22 +197,22 @@ 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 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( @@ -226,22 +226,22 @@ 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 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( @@ -255,23 +255,23 @@ 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 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) + async def test_assistant_events_conversation_kwargs_disabled(self): + app = AsyncApp(client=self.web_client, attaching_conversation_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_ignore_self.py b/tests/scenario_tests_async/test_events_ignore_self.py index 7ec9d0cce..fa398a907 100644 --- a/tests/scenario_tests_async/test_events_ignore_self.py +++ b/tests/scenario_tests_async/test_events_ignore_self.py @@ -89,6 +89,7 @@ async def test_self_events_disabled(self): "type": "message", "channel": "C111", "ts": "1599529504.000400", + "channel_type": "channel", }, "reaction": "heart_eyes", "item_user": "W111", 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..6abcfad88 --- /dev/null +++ b/tests/scenario_tests_async/test_events_say_stream.py @@ -0,0 +1,235 @@ +import asyncio +import json +from urllib.parse import quote + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +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_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 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) + listener_called = asyncio.Event() + + @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 + listener_called.set() + + request = AsyncBoltRequest(body=app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + 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) + listener_called = asyncio.Event() + + @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" + listener_called.set() + + request = AsyncBoltRequest(body=org_app_mention_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + 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) + listener_called = asyncio.Event() + + @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 + listener_called.set() + + request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + 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) + listener_called = asyncio.Event() + + @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 + listener_called.set() + + request = AsyncBoltRequest(body=user_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + 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) + listener_called = asyncio.Event() + + @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 + listener_called.set() + + request = AsyncBoltRequest(body=bot_message_event_payload, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + 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() + listener_called = asyncio.Event() + + @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 + 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 (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() + listener_called = asyncio.Event() + + @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 + listener_called.set() + + app.assistant(assistant) + + request = AsyncBoltRequest(body=threaded_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + 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) + 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 + listener_called.set() + + request = AsyncBoltRequest( + body=f"payload={quote(json.dumps(view_submission_body))}", + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert (await asyncio.wait_for(listener_called.wait(), timeout=0.1)) is True + + +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_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/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py index abf3ffb48..ce9080e36 100644 --- a/tests/scenario_tests_async/test_function.py +++ b/tests/scenario_tests_async/test_function.py @@ -8,6 +8,7 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient +import slack_bolt.listener.asyncio_runner as async_runner_module from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( @@ -19,10 +20,6 @@ from tests.utils import remove_os_env_temporarily, restore_os_env -async def fake_sleep(seconds): - pass - - class TestAsyncFunction: signing_secret = "secret" valid_token = "xoxb-valid" @@ -60,9 +57,9 @@ def build_request_from_body(self, message_body: dict) -> AsyncBoltRequest: timestamp, body = str(int(time.time())), json.dumps(message_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) - def setup_time_mocks(self, *, monkeypatch: pytest.MonkeyPatch, time_mock: Mock, sleep_mock: MagicMock): - monkeypatch.setattr(time, "time", time_mock) - monkeypatch.setattr(asyncio, "sleep", sleep_mock) + def setup_time_mocks(self, *, monkeypatch: pytest.MonkeyPatch, time_mock, sleep_mock): + monkeypatch.setattr(async_runner_module.time, "time", time_mock) + monkeypatch.setattr(async_runner_module.asyncio, "sleep", sleep_mock) @pytest.mark.asyncio async def test_mock_server_is_running(self): @@ -146,9 +143,18 @@ async def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkey app.function("reverse", auto_acknowledge=False)(just_no_ack) request = self.build_request_from_body(function_body) + elapsed_seconds = 0 + + def fake_time(): + return float(elapsed_seconds) + + async def fake_sleep(duration): + nonlocal elapsed_seconds + elapsed_seconds += 1 + self.setup_time_mocks( monkeypatch=monkeypatch, - time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + time_mock=fake_time, sleep_mock=MagicMock(side_effect=fake_sleep), ) @@ -167,20 +173,28 @@ async def test_function_handler_timeout(self, monkeypatch): app.function("reverse", auto_acknowledge=False, ack_timeout=timeout)(just_no_ack) request = self.build_request_from_body(function_body) - sleep_mock = MagicMock(side_effect=fake_sleep) + elapsed_seconds = 0 + + def fake_time(): + return float(elapsed_seconds) + + async def fake_sleep(duration): + nonlocal elapsed_seconds + elapsed_seconds += 1 + self.setup_time_mocks( monkeypatch=monkeypatch, - time_mock=Mock(side_effect=[current_time for current_time in range(100)]), - sleep_mock=sleep_mock, + time_mock=fake_time, + sleep_mock=MagicMock(side_effect=fake_sleep), ) response = await app.async_dispatch(request) assert response.status == 404 await assert_auth_test_count_async(self, 1) - assert ( - sleep_mock.call_count == timeout - ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + assert elapsed_seconds == timeout + 1, ( + f"Expected handler to time out after {timeout + 1} sleep calls, " f"but it was called {elapsed_seconds} times" + ) @pytest.mark.asyncio async def test_warning_when_timeout_improperly_set(self, caplog): 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) 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/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/context/test_say_stream.py b/tests/slack_bolt/context/test_say_stream.py new file mode 100644 index 000000000..04a52e419 --- /dev/null +++ b/tests/slack_bolt/context/test_say_stream.py @@ -0,0 +1,108 @@ +import pytest +from unittest.mock import patch, MagicMock + +from slack_sdk import WebClient + +from slack_bolt.context.say_stream.say_stream import SayStream + + +class TestSayStream: + def setup_method(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") + 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.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", + ) + 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( + 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(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( + 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( + 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, + ) + + 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/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py index 792b974b5..a743d5656 100644 --- a/tests/slack_bolt/context/test_set_suggested_prompts.py +++ b/tests/slack_bolt/context/test_set_suggested_prompts.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock, patch + import pytest from slack_sdk import WebClient from slack_sdk.web import SlackResponse @@ -31,6 +33,47 @@ def test_set_suggested_prompts_objects(self): ) assert response.status_code == 200 + def test_set_suggested_prompts_without_thread_ts(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"]) + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts=None, + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + def test_set_suggested_prompts_thread_ts_override(self): + # The call-time thread_ts must win over the stored one + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="999.999") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="123.123", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + def test_set_suggested_prompts_thread_ts_override_falsy(self): + # An explicitly passed falsy thread_ts must be forwarded, not swallowed by the stored value + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"], thread_ts="") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + def test_set_suggested_prompts_invalid(self): set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") with pytest.raises(TypeError): 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 deleted file mode 100644 index f56bd2e62..000000000 --- a/tests/slack_bolt/middleware/attaching_agent_kwargs/test_attaching_agent_kwargs.py +++ /dev/null @@ -1,64 +0,0 @@ -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/agent/__init__.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py similarity index 100% rename from tests/slack_bolt/agent/__init__.py rename to tests/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py diff --git a/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py new file mode 100644 index 000000000..3f46a6b67 --- /dev/null +++ b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py @@ -0,0 +1,197 @@ +from slack_sdk import WebClient + +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 ( + build_payload, + thread_started_event_body, + user_message_event_body, + channel_user_message_event_body, +) + + +def next(): + return BoltResponse(status=200) + + +ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") + +# A top-level DM (not in a thread) is not an assistant thread, but set_suggested_prompts is still attached. +top_level_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "A top-level DM, not in a thread", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A bot-authored top-level DM is also in scope: set_suggested_prompts is attached for any IM message. +bot_im_message_event_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "A DM authored by a bot", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A file_share DM is in scope too (subtype "file_share" passes is_im_message_event). +file_share_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# Opening the Messages tab of App Home is in scope for set_suggested_prompts. +app_home_opened_messages_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +# Opening the Home tab is NOT in scope: set_suggested_prompts should not be attached. +app_home_opened_home_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + + +class TestAttachingConversationKwargs: + def test_assistant_event_attaches_kwargs(self): + middleware = AttachingConversationKwargs() + 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 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 = AttachingConversationKwargs() + 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 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_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=top_level_im_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 + assert "set_suggested_prompts" in req.context + assert "set_title" not in req.context + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + assert "say_stream" in req.context + assert "set_status" in req.context + + def test_bot_dm_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=bot_im_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 + assert "set_suggested_prompts" in req.context + + def test_file_share_dm_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=file_share_im_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 + assert "set_suggested_prompts" in req.context + + def test_app_home_opened_messages_tab_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=app_home_opened_messages_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 + assert "set_suggested_prompts" in req.context + assert "say" not in req.context + assert "set_title" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + + def test_app_home_opened_home_tab_does_not_attach_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=app_home_opened_home_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 + assert "set_suggested_prompts" not in req.context + + def test_non_assistant_event_does_not_attach_kwargs(self): + middleware = AttachingConversationKwargs() + 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 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 = AttachingConversationKwargs() + req = BoltRequest(body="payload={}", headers={}) + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + 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/middleware/request_verification/test_request_verification.py b/tests/slack_bolt/middleware/request_verification/test_request_verification.py index 2c9adea43..53af43bf3 100644 --- a/tests/slack_bolt/middleware/request_verification/test_request_verification.py +++ b/tests/slack_bolt/middleware/request_verification/test_request_verification.py @@ -1,5 +1,6 @@ from time import time +import pytest from slack_sdk.signature import SignatureVerifier from slack_bolt.middleware import RequestVerification @@ -45,3 +46,36 @@ 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"}""" + + def test_empty_signing_secret_does_not_raise_on_init(self): + RequestVerification(signing_secret="") + + def test_socket_mode_request_skips_verification_without_signing_secret(self): + middleware = RequestVerification(signing_secret="") + req = BoltRequest(mode="socket_mode", body="payload={}", headers={}) + resp = BoltResponse(status=404, body="default") + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 200 + assert resp.body == "next" + + def test_http_request_with_empty_signing_secret_raises(self): + middleware = RequestVerification(signing_secret="") + req = BoltRequest(body="payload={}", headers={}) + resp = BoltResponse(status=404) + with pytest.raises(ValueError): + middleware.process(req=req, resp=resp, next=next) diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 0b267e3de..31ac35bdd 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -1248,3 +1248,30 @@ 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"}, + "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"}, + "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) diff --git a/tests/slack_bolt/request/test_payload_utils.py b/tests/slack_bolt/request/test_payload_utils.py new file mode 100644 index 000000000..576cb390d --- /dev/null +++ b/tests/slack_bolt/request/test_payload_utils.py @@ -0,0 +1,408 @@ +from slack_bolt.request.payload_utils import ( + is_event, + is_message_event, + is_any_im_message_event, + is_im_message_event, + is_assistant_event, + is_assistant_thread_started_event, + is_assistant_thread_context_changed_event, + is_app_home_opened_event, + is_user_message_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, + is_other_message_sub_event_in_assistant_thread, +) +from tests.scenario_tests.test_events_assistant import ( + build_payload, + thread_started_event_body, + thread_context_changed_event_body, + user_message_event_body, + user_message_event_body_with_assistant_thread, + message_changed_event_body, + channel_user_message_event_body, + channel_message_changed_event_body, +) +from tests.scenario_tests.test_message_bot import ( + bot_message_event_payload, + classic_bot_message_event_payload, +) +from tests.scenario_tests.test_message_deleted import event_payload as message_deleted_channel_body +from tests.scenario_tests.test_events_ignore_self import event_body as reaction_added_event_body +from tests.scenario_tests.test_block_actions import body as block_actions_body + +file_share_im_message_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "files": [ + { + "id": "F111", + "created": 1726133700, + "name": "test.png", + "title": "test.png", + "mimetype": "image/png", + "filetype": "png", + "user": "W222", + "size": 12345, + "mode": "hosted", + "is_external": False, + "is_public": False, + "url_private": "https://files.slack.com/files-pri/T111-F111/test.png", + "permalink": "https://example.slack.com/files/W222/F111/test.png", + } + ], + "upload": True, + "display_as_bot": False, + "thread_ts": "1726133698.626339", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +bot_im_thread_message_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "Here is your answer", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "bot_profile": { + "id": "B111", + "deleted": False, + "name": "assistant-app", + "updated": 1726133600, + "app_id": "A222", + "team_id": "T111", + }, + "thread_ts": "1726133698.626339", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +im_message_no_thread_ts_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "A top-level DM, not in a thread", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +app_home_opened_messages_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +app_home_opened_home_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + +slash_command_body = { + "token": "verification_token", + "command": "/test", + "text": "hello", + "user_id": "U111", + "user_name": "primary-owner", + "channel_id": "C111", + "channel_name": "test-channel", + "team_id": "T111", + "team_domain": "test-domain", + "api_app_id": "A111", + "is_enterprise_install": "false", + "response_url": "https://hooks.slack.com/commands/T111/111/xxx", + "trigger_id": "111.222.xxx", +} + + +class TestPayloadUtils: + def test_is_message_event(self): + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "slash_command": slash_command_body, + "empty_dict": {}, + } + for key, body in positives.items(): + assert is_message_event(body), f"{key} should be recognized as a message event" + for key, body in negatives.items(): + assert not is_message_event(body), f"{key} should NOT be recognized as a message event" + + def test_is_any_im_message_event(self): + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "message_changed_im": message_changed_event_body, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "slash_command": slash_command_body, + } + for key, body in positives.items(): + assert is_any_im_message_event(body), f"{key} should pass {is_any_im_message_event.__name__}" + for key, body in negatives.items(): + assert not is_any_im_message_event(body), f"{key} should NOT pass {is_any_im_message_event.__name__}" + + def test_is_im_message_event(self): + # subtype must be None or "file_share" to pass + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "classic_bot_message_channel": classic_bot_message_event_payload, + "bot_message_channel": bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_im_message_event(body), f"{key} should pass {is_im_message_event.__name__}" + for key, body in negatives.items(): + assert not is_im_message_event(body), f"{key} should NOT pass {is_im_message_event.__name__}" + + def test_is_event(self): + positives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "reaction_added": reaction_added_event_body, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "block_actions": block_actions_body, + "slash_command": slash_command_body, + "empty_dict": {}, + } + for key, body in positives.items(): + assert is_event(body), f"{key} should be recognized as an event" + for key, body in negatives.items(): + assert not is_event(body), f"{key} should NOT be recognized as an event" + + def test_is_user_message_event_in_assistant_thread(self): + # Requires: is_im_message_event + thread_ts present + bot_id absent + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "file_share_im": file_share_im_message_body, + } + negatives = { + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_user_message_event_in_assistant_thread( + body + ), f"{key} should pass {is_user_message_event_in_assistant_thread.__name__}" + for key, body in negatives.items(): + assert not is_user_message_event_in_assistant_thread( + body + ), f"{key} should NOT pass {is_user_message_event_in_assistant_thread.__name__}" + + def test_is_bot_message_event_in_assistant_thread(self): + positives = { + "bot_im_thread": bot_im_thread_message_body, + } + negatives = { + "user_message_im": user_message_event_body, + "file_share_im": file_share_im_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_bot_message_event_in_assistant_thread( + body + ), f"{key} should pass {is_bot_message_event_in_assistant_thread.__name__}" + for key, body in negatives.items(): + assert not is_bot_message_event_in_assistant_thread( + body + ), f"{key} should NOT pass {is_bot_message_event_in_assistant_thread.__name__}" + + def test_is_bot_message_user_message_asymmetry(self): + assert is_user_message_event_in_assistant_thread(file_share_im_message_body) + assert not is_bot_message_event_in_assistant_thread(file_share_im_message_body) + + assert is_bot_message_event_in_assistant_thread(bot_im_thread_message_body) + assert not is_user_message_event_in_assistant_thread(bot_im_thread_message_body) + + def test_is_other_message_sub_event_in_assistant_thread(self): + positives = { + "message_changed_im": message_changed_event_body, + } + negatives = { + "user_message_im": user_message_event_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + "channel_message_changed": channel_message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_other_message_sub_event_in_assistant_thread( + body + ), f"{key} should pass {is_other_message_sub_event_in_assistant_thread.__name__}" + for key, body in negatives.items(): + assert not is_other_message_sub_event_in_assistant_thread( + body + ), f"{key} should NOT pass {is_other_message_sub_event_in_assistant_thread.__name__}" + + def test_is_assistant_event(self): + positives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + } + negatives = { + "message_changed_im": message_changed_event_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_assistant_event(body), f"{key} should pass {is_assistant_event.__name__}" + for key, body in negatives.items(): + assert not is_assistant_event(body), f"{key} should NOT pass {is_assistant_event.__name__}" + + def test_is_assistant_thread_started_event(self): + assert is_assistant_thread_started_event(thread_started_event_body) + + negatives = { + "thread_context_changed": thread_context_changed_event_body, + "user_message_im": user_message_event_body, + "message_changed_im": message_changed_event_body, + "bot_im_thread": bot_im_thread_message_body, + "channel_user_message": channel_user_message_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in negatives.items(): + assert not is_assistant_thread_started_event( + body + ), f"{key} should NOT pass {is_assistant_thread_started_event.__name__}" + + def test_is_assistant_thread_context_changed_event(self): + assert is_assistant_thread_context_changed_event(thread_context_changed_event_body) + + negatives = { + "thread_started": thread_started_event_body, + "user_message_im": user_message_event_body, + "message_changed_im": message_changed_event_body, + "bot_im_thread": bot_im_thread_message_body, + "channel_user_message": channel_user_message_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in negatives.items(): + assert not is_assistant_thread_context_changed_event( + body + ), f"{key} should NOT pass {is_assistant_thread_context_changed_event.__name__}" + + def test_is_app_home_opened_event(self): + assert is_app_home_opened_event(app_home_opened_messages_body) + assert is_app_home_opened_event(app_home_opened_home_body) + + assert is_app_home_opened_event(app_home_opened_messages_body, tab="messages") + assert not is_app_home_opened_event(app_home_opened_home_body, tab="messages") + + negatives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "user_message_im": user_message_event_body, + "channel_user_message": channel_user_message_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "empty_dict": {}, + } + for key, body in negatives.items(): + assert not is_app_home_opened_event(body), f"{key} should NOT pass {is_app_home_opened_event.__name__}" + assert not is_app_home_opened_event( + body, tab="messages" + ), f"{key} should NOT pass {is_app_home_opened_event.__name__} with tab='messages'" 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 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..7ac084044 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -0,0 +1,141 @@ +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.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncSayStream: + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + try: + self.web_client = AsyncWebClient(token="xoxb-valid") + yield + finally: + 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.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.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", + ) + 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() + 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): + 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) + + 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): + 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) + + 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) + + 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/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py index 2a09434a8..7a92ff2a3 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 @@ -1,4 +1,5 @@ import asyncio +from unittest.mock import MagicMock, patch import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -41,6 +42,65 @@ async def test_set_suggested_prompts_objects(self): ) assert response.status_code == 200 + @pytest.mark.asyncio + async def test_set_suggested_prompts_without_thread_ts(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"]) + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts=None, + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_thread_ts_override(self): + # The call-time thread_ts must win over the stored one + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="999.999") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="123.123", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_thread_ts_override_falsy(self): + # An explicitly passed falsy thread_ts must be forwarded, not swallowed by the stored value + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"], thread_ts="") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + @pytest.mark.asyncio async def test_set_suggested_prompts_invalid(self): set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") diff --git a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/__init__.py b/tests/slack_bolt_async/middleware/attaching_agent_kwargs/__init__.py deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 55883e5f3..000000000 --- a/tests/slack_bolt_async/middleware/attaching_agent_kwargs/test_async_attaching_agent_kwargs.py +++ /dev/null @@ -1,69 +0,0 @@ -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" diff --git a/tests/slack_bolt/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/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_conversation_kwargs/test_async_attaching_conversation_kwargs.py b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py new file mode 100644 index 000000000..c7da4ce93 --- /dev/null +++ b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py @@ -0,0 +1,209 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +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 ( + build_payload, + thread_started_event_body, + user_message_event_body, + channel_user_message_event_body, +) + + +async def next(): + return BoltResponse(status=200) + + +ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") + +# A top-level DM (not in a thread) is not an assistant thread, but set_suggested_prompts is still attached. +top_level_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "A top-level DM, not in a thread", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A bot-authored top-level DM is also in scope: set_suggested_prompts is attached for any IM message. +bot_im_message_event_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "A DM authored by a bot", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A file_share DM is in scope too (subtype "file_share" passes is_im_message_event). +file_share_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# Opening the Messages tab of App Home is in scope for set_suggested_prompts. +app_home_opened_messages_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +# Opening the Home tab is NOT in scope: set_suggested_prompts should not be attached. +app_home_opened_home_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + + +class TestAsyncAttachingConversationKwargs: + @pytest.mark.asyncio + async def test_assistant_event_attaches_kwargs(self): + middleware = AsyncAttachingConversationKwargs() + 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 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): + middleware = AsyncAttachingConversationKwargs() + 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 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_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=top_level_im_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 + assert "set_suggested_prompts" in req.context + assert "set_title" not in req.context + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + assert "say_stream" in req.context + assert "set_status" in req.context + + @pytest.mark.asyncio + async def test_bot_dm_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=bot_im_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 + assert "set_suggested_prompts" in req.context + + @pytest.mark.asyncio + async def test_file_share_dm_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=file_share_im_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 + assert "set_suggested_prompts" in req.context + + @pytest.mark.asyncio + async def test_app_home_opened_messages_tab_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=app_home_opened_messages_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 + assert "set_suggested_prompts" in req.context + assert "say" not in req.context + assert "set_title" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + + @pytest.mark.asyncio + async def test_app_home_opened_home_tab_does_not_attach_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=app_home_opened_home_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 + assert "set_suggested_prompts" not in req.context + + @pytest.mark.asyncio + async def test_non_assistant_event_does_not_attach_kwargs(self): + middleware = AsyncAttachingConversationKwargs() + 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 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): + middleware = AsyncAttachingConversationKwargs() + 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 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/request_verification/test_request_verification.py b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py index c097dd146..126b04f94 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,39 @@ 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"}""" + + def test_empty_signing_secret_does_not_raise_on_init(self): + AsyncRequestVerification(signing_secret="") + + @pytest.mark.asyncio + async def test_socket_mode_request_skips_verification_without_signing_secret(self): + middleware = AsyncRequestVerification(signing_secret="") + req = AsyncBoltRequest(mode="socket_mode", body="payload={}", headers={}) + resp = BoltResponse(status=404, body="default") + resp = await middleware.async_process(req=req, resp=resp, next=next) + assert resp.status == 200 + assert resp.body == "next" + + @pytest.mark.asyncio + async def test_http_request_with_empty_signing_secret_raises(self): + middleware = AsyncRequestVerification(signing_secret="") + req = AsyncBoltRequest(body="payload={}", headers={}) + resp = BoltResponse(status=404) + with pytest.raises(ValueError): + await middleware.async_process(req=req, resp=resp, next=next)