diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 000000000..3a2f7f6a1 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,4 @@ +CLAUDE.local.md +settings.local.json +worktrees/ +plans/ diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..dba71e970 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +@../AGENTS.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..705fd286c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,34 @@ +{ + "permissions": { + "allow": [ + "Bash(./scripts/build_pypi_package.sh:*)", + "Bash(./scripts/format.sh:*)", + "Bash(./scripts/install_all_and_run_tests.sh:*)", + "Bash(./scripts/lint.sh:*)", + "Bash(./scripts/run_mypy.sh:*)", + "Bash(./scripts/run_tests.sh:*)", + "Bash(./scripts/install.sh:*)", + "Bash(echo $VIRTUAL_ENV)", + "Bash(gh issue view:*)", + "Bash(gh label list:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr list:*)", + "Bash(gh pr status:*)", + "Bash(gh pr update-branch:*)", + "Bash(gh pr view:*)", + "Bash(gh search code:*)", + "Bash(git diff:*)", + "Bash(git grep:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git status:*)", + "Bash(grep:*)", + "Bash(ls:*)", + "Bash(tree:*)", + "WebFetch(domain:github.com)", + "WebFetch(domain:docs.slack.dev)", + "WebFetch(domain:raw.githubusercontent.com)" + ] + } +} diff --git a/.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/.flake8 b/.flake8 new file mode 100644 index 000000000..9960c210e --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 125 +ignore = F841,F821,W503,E402 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..9e2a40167 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# change black settings +0e4cd56b69e8f83166cd262f762802b7f18c3d21 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..4a08579c2 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Salesforce Open Source project configuration +# Learn more: https://github.com/salesforce/oss-template +#ECCN:Open Source +#GUSINFO:Open Source,Open Source Workflow + +# @slackapi/slack-platform-python +# are code reviewers for all changes in this repo. +* @slackapi/slack-platform-python + +# @slackapi/developer-education +# are code reviewers for changes in the `/docs` directory. +/docs/ @slackapi/developer-education diff --git a/.github/ISSUE_TEMPLATE/01_question.md b/.github/ISSUE_TEMPLATE/01_question.md index d8dc6433e..ae7a70718 100644 --- a/.github/ISSUE_TEMPLATE/01_question.md +++ b/.github/ISSUE_TEMPLATE/01_question.md @@ -1,5 +1,5 @@ --- -name: Question +name: SDK Question about: Submit a question about this SDK title: (Set a clear title describing your question) labels: 'untriaged' @@ -46,4 +46,4 @@ sw_vers && uname -v # or `ver` ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/ISSUE_TEMPLATE/02_enhancement.md b/.github/ISSUE_TEMPLATE/02_enhancement.md index fd9007c24..54aea96ed 100644 --- a/.github/ISSUE_TEMPLATE/02_enhancement.md +++ b/.github/ISSUE_TEMPLATE/02_enhancement.md @@ -1,5 +1,5 @@ --- -name: Enhancement / Feature Request +name: SDK Enhancement / Feature Request about: Submit an enhancement/feature request title: (Set a clear title describing your idea) labels: 'untriaged' @@ -13,7 +13,8 @@ assignees: '' * [ ] **slack_bolt.App** and/or its core components * [ ] **slack_bolt.async_app.AsyncApp** and/or its core components * [ ] Adapters in **slack_bolt.adapter** +* [ ] Others ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/ISSUE_TEMPLATE/03_document.md b/.github/ISSUE_TEMPLATE/03_document.md index 7e0f8e2cc..4eb5af847 100644 --- a/.github/ISSUE_TEMPLATE/03_document.md +++ b/.github/ISSUE_TEMPLATE/03_document.md @@ -1,5 +1,5 @@ --- -name: Document +name: SDK Document about: Submit an issue on documents title: (Set a clear title describing your idea) labels: 'untriaged' @@ -10,8 +10,8 @@ assignees: '' ### The page URLs -* https://slack.dev/bolt-python/ +* https://docs.slack.dev/tools/bolt-python/ ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/ISSUE_TEMPLATE/04_bug.md b/.github/ISSUE_TEMPLATE/04_bug.md index 04c3eda54..b88531bed 100644 --- a/.github/ISSUE_TEMPLATE/04_bug.md +++ b/.github/ISSUE_TEMPLATE/04_bug.md @@ -1,5 +1,5 @@ --- -name: Bug +name: SDK Bug about: Report the SDK bug title: (Set a clear title describing the issue) labels: 'untriaged' @@ -46,4 +46,4 @@ sw_vers && uname -v # or `ver` ## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..a26c2cd6a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Slack Platform Customer Support + url: https://my.slack.com/help/requests/new + about: | + This issue tracker is a place to track bugs, feature requests, and questions on this SDK side. + + If you have a general question on how to use the Slack platform, please get in touch with our customer support agents first via either /feedback in your Slack workspace or the help page link here. diff --git a/.github/contributing.md b/.github/contributing.md index 3b1a5378f..8d8d668a6 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -35,7 +35,7 @@ Issues labelled `good first contribution`. For your contribution to be accepted: -- [x] You must have signed the [Contributor License Agreement (CLA)](https://cla-assistant.io/slackapi/bolt-python). +- [x] You must have signed the [Contributor License Agreement (CLA)](https://cla.salesforce.com/sign-cla). - [x] The test suite must be complete and pass. - [x] The changes must be approved by code review. - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. @@ -57,4 +57,4 @@ If the contribution doesn't meet the above criteria, you may fail our automated ## Maintainers -There are more details about processes and workflow in the [Maintainer's Guide](./maintainers_guide.md). \ No newline at end of file +There are more details about processes and workflow in the [Maintainer's Guide](./maintainers_guide.md). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..774d13833 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + ignore: + # setuptools is pinned due to pyramid's dependency on deprecated pkg_resources + # See: https://github.com/Pylons/pyramid/issues/3731 + - dependency-name: "setuptools" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 000000000..186797b8f --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,41 @@ +(Describe your issue and goal here) + +### Reproducible in: + +```bash +pip freeze | grep slack +python --version +sw_vers && uname -v # or `ver` +``` + +#### The `slack_bolt` version + +(Paste the output of `pip freeze | grep slack`) + +#### Python runtime version + +(Paste the output of `python --version`) + +#### OS info + +(Paste the output of `sw_vers && uname -v` on macOS/Linux or `ver` on Windows OS) + +#### Steps to reproduce: + +(Share the commands to run, source code, and project settings (e.g., setup.py)) + +1. +2. +3. + +### Expected result: + +(Tell what you expected to happen) + +### Actual result: + +(Tell what actually happened with logs, screenshots) + +## Requirements + +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. diff --git a/.github/maintainers_guide.md b/.github/maintainers_guide.md index deb40fa94..47d9ccd58 100644 --- a/.github/maintainers_guide.md +++ b/.github/maintainers_guide.md @@ -10,14 +10,14 @@ this project. If you use this package within your own software as is but don't p We recommend using [pyenv](https://github.com/pyenv/pyenv) for Python runtime management. If you use macOS, follow the following steps: -```bash -$ brew update -$ brew install pyenv +```sh +brew update +brew install pyenv ``` -Install necessary Python runtimes for development/testing. You can rely on Travis CI builds for testing with various major versions. https://github.com/slackapi/bolt-python/blob/main/.travis.yml +Install necessary Python runtimes for development/testing. You can rely on GitHub Actions workflows for testing with various major versions. -```bash +```sh $ pyenv install -l | grep -v "-e[conda|stackless|pypy]" $ pyenv install 3.8.5 # select the latest patch version @@ -25,8 +25,8 @@ $ pyenv local 3.8.5 $ pyenv versions system - 3.6.10 - 3.7.7 + 3.7.17 + 3.13.7 * 3.8.5 (set by /path-to-bolt-python/.python-version) $ pyenv rehash @@ -34,9 +34,9 @@ $ pyenv rehash Then, you can create a new Virtual Environment this way: -``` -$ python -m venv env_3.8.5 -$ source env_3.8.5/bin/activate +```sh +python -m venv env_3.8.5 +source env_3.8.5/bin/activate ``` ## Tasks @@ -45,114 +45,179 @@ $ source env_3.8.5/bin/activate #### Run All the Unit Tests -If you make some changes to this SDK, please write corresponding unit tests as much as possible. You can easily run all the tests by running the following script +If you make some changes to this SDK, please write corresponding unit tests as much as possible. You can easily run all the tests by running the following script. + +If this is your first time to run tests, although it may take a bit long time, running the following script is the easiest. + +```sh +./scripts/install_all_and_run_tests.sh +``` + +Once you installed all the required dependencies, you can use the following one. + +```sh +./scripts/run_tests.sh +``` + +Also, you can run a single test this way. -```bash -$ ./scripts/run_tests.sh +```sh +./scripts/run_tests.sh tests/scenario_tests/test_app.py ``` #### Run the Samples -If you make changes to `slack_bolt/adapter/*`, please verify if it surely works by running the apps under `samples` directory. +If you make changes to `slack_bolt/adapter/*`, please verify if it surely works by running the apps under `examples` directory. -```bash +```sh # Install all optional dependencies -$ pip install -e ".[adapter]" +$ pip install -r requirements/adapter_dev.txt +$ pip install -r requirements/test_adapter.txt # Set required env variables $ export SLACK_SIGNING_SECRET=*** $ export SLACK_BOT_TOKEN=xoxb-*** # Standalone apps -$ cd samples/ +$ cd examples/ $ python app.py $ python async_app.py # Flask apps -$ cd samples/flask +$ cd examples/flask $ FLASK_APP=app.py FLASK_ENV=development flask run -p 3000 # In another terminal $ ngrok http 3000 --subdomain {your-domain} ``` -### Releasing - -#### test.pypi.org deployment +#### Develop Locally -##### $HOME/.pypirc - -``` -[testpypi] -username: {your username} -password: {your password} -``` +If you want to test the package locally you can. -##### Deployment +1. Build the package locally + - Run + ```sh + scripts/build_pypi_package.sh + ``` + - This will create a `.whl` file in the `./dist` folder +2. Use the built package + - Example `/dist/slack_bolt-1.2.3-py2.py3-none-any.whl` was created + - From anywhere on your machine you can install this package to a project with + ```sh + pip install /dist/slack_bolt-1.2.3-py2.py3-none-any.whl + ``` + - It is also possible to include `slack_bolt @ file:////dist/slack_bolt-1.2.3-py2.py3-none-any.whl` in a [requirements.txt](https://pip.pypa.io/en/stable/user_guide/#requirements-files) file -You can deploy a new version using `./scripts/deploy_to_test_pypi_org.sh`. +### Generate API reference documents -```bash -$ echo '__version__ = "{the version}"' > slack_bolt/version.py -$ ./scripts/deploy_to_test_pypi_org.sh +```sh +./scripts/generate_api_docs.sh ``` -#### Production Deployment - -1. Create the commit for the release: - -- Bump the version number in adherence to [Semantic Versioning](http://semver.org/) in `slack_bolt/version.py` - - `echo '__version__ = "1.2.3"' > slack_bolt/version.py` -- Commit with a message including the new version number. For example `1.2.3` & Push the commit to a branch and create a PR to sanity check. - - `git checkout -b v1.2.3-release` - - `git commit -m'version 1.2.3'` - - `git push {your-fork} v1.2.3-release` -- Merge in release PR after getting an approval from at least one maintainer. -- Create a git tag for the release. For example `git tag v1.2.3`. -- Push the tag up to github with `git push origin --tags` - -2. Distribute the release - -- Use the latest stable Python runtime -- `python -m venv env` -- `./scripts/deploy_to_prod_pypi_org.sh` -- Create a GitHub release - https://github.com/slackapi/bolt-python/releases +### Releasing -```markdown -## New Features +#### test.pypi.org deployment -### Awesome Feature 1 +[TestPyPI](https://test.pypi.org/) is a separate instance of the Python Package +Index that allows you to try distribution tools and processes without affecting +the real index. This is particularly useful when making changes related to the +package configuration itself, for example, modifications to the `pyproject.toml` file. -Description here. +You can deploy this project to TestPyPI using GitHub Actions. -### Awesome Feature 2 +To deploy using GitHub Actions: -Description here. +1. Push your changes to a branch or tag +2. Navigate to +3. Click on "Run workflow" +4. Select your branch or tag from the dropdown +5. Click "Run workflow" to build and deploy your branch to TestPyPI -## Changes +Alternatively, you can deploy from your local machine with: -* #123 Make it better - thanks @SlackHQ -* #123 Fix something wrong - thanks @seratch +```sh +./scripts/deploy_to_test_pypi.sh ``` -3. (Slack Internal) Communicate the release internally +#### Development Deployment + +Deploying a new version of this library to PyPI is triggered by publishing a GitHub Release. +Before creating a new release, ensure that everything on a stable branch has +landed, then [run the tests](#run-all-the-unit-tests). + +1. Create the commit for the release + 1. Use the latest supported Python version. Using a [virtual environment](#python-and-friends) is recommended. + 2. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and [Developmental Release](https://peps.python.org/pep-0440/#developmental-releases). + - Example: if the current version is `1.2.3`, a proper development bump would be `1.2.4.dev0` + - `.dev` will indicate to pip that this is a [Development Release](https://peps.python.org/pep-0440/#developmental-releases) + - Note that the `dev` version can be bumped in development releases: `1.2.4.dev0` -> `1.2.4.dev1` + 3. Build the docs with `./scripts/generate_api_docs.sh`. + 4. Commit with a message including the new version number. For example `1.2.4.dev0` & push the commit to a branch where the development release will live (create it if it does not exist) + 1. `git checkout -b future-release` + 2. `git add --all` (review files with `git status` before committing) + 3. `git commit -m 'chore(release): version 1.2.4.dev0'` + 4. `git push -u origin future-release` +2. Create a new GitHub Release + 1. Navigate to the [Releases page](https://github.com/slackapi/bolt-python/releases). + 2. Click the "Draft a new release" button. + 3. Set the "Target" to the feature branch with the development changes. + 4. Click "Tag: Select tag" + 5. Input a new tag name manually. The tag name must match the version in `slack_bolt/version.py` prefixed with "v" (e.g., if version is `1.2.4.dev0`, enter `v1.2.4.dev0`) + 6. Click the "Create a new tag" button. This won't create your tag immediately. + 7. Click the "Generate release notes" button. + 8. The release name should match the tag name! + 9. Edit the resulting notes to ensure they have decent messaging that is understandable by non-contributors, but each commit should still have its own line. + 10. Set this release as a pre-release. + 11. Publish the release by clicking the "Publish release" button! +3. Navigate to the [release workflow run](https://github.com/slackapi/bolt-python/actions/workflows/pypi-release.yml). You will need to approve the deployment! +4. After a few minutes, the corresponding version will be available on . +5. (Slack Internal) Communicate the release internally -- Include a link to the GitHub release - -4. Make announcements - -- #slack-api in dev4slack.slack.com -- #tools-bolt in community.slack.com - -5. (Slack Internal) Tweet by @SlackAPI +#### Production Deployment -- Not necessary for patch updates, might be needed for minor updates, definitely needed for major updates. Include a link to the GitHub release +Deploying a new version of this library to PyPI is triggered by publishing a GitHub Release. +Before creating a new release, ensure that everything on the `main` branch since +the last tag is in a releasable state! At a minimum, [run the tests](#run-all-the-unit-tests). + +1. Create the commit for the release + 1. Use the latest supported Python version. Using a [virtual environment](#python-and-friends) is recommended. + 2. In `slack_bolt/version.py` bump the version number in adherence to [Semantic Versioning](http://semver.org/) and the [Versioning](#versioning-and-tags) section. + 3. Build the docs with `./scripts/generate_api_docs.sh`. + 4. Commit with a message including the new version number. For example `1.2.3` & push the commit to a branch and create a PR to sanity check. + 1. `git checkout -b 1.2.3-release` + 2. `git add --all` (review files with `git status` before committing) + 3. `git commit -m 'chore(release): version 1.2.3'` + 4. `git push -u origin 1.2.3-release` + 5. Add relevant labels to the PR and add the PR to a GitHub Milestone. + 6. Merge in release PR after getting an approval from at least one maintainer. +2. Create a new GitHub Release + 1. Navigate to the [Releases page](https://github.com/slackapi/bolt-python/releases). + 2. Click the "Draft a new release" button. + 3. Set the "Target" to the `main` branch. + 4. Click "Tag: Select tag" + 5. Input a new tag name manually. The tag name must match the version in `slack_bolt/version.py` prefixed with "v" (e.g., if version is `1.2.3`, enter `v1.2.3`) + 6. Click the "Create a new tag" button. This won't create your tag immediately. + 7. Click the "Generate release notes" button. + 8. The release name should match the tag name! + 9. Edit the resulting notes to ensure they have decent messaging that is understandable by non-contributors, but each commit should still have its own line. + 10. Include a link to the current GitHub Milestone. + 11. Ensure the "latest release" checkbox is checked to mark this as the latest stable release. + 12. Publish the release by clicking the "Publish release" button! +3. Navigate to the [release workflow run](https://github.com/slackapi/bolt-python/actions/workflows/pypi-release.yml). You will need to approve the deployment! +4. After a few minutes, the corresponding version will be available on . +5. Close the current GitHub Milestone and create one for the next patch version. +6. (Slack Internal) Communicate the release internally + - Include a link to the GitHub release +7. (Slack Internal) Tweet by @SlackAPI + - Not necessary for patch updates, might be needed for minor updates, + definitely needed for major updates. Include a link to the GitHub release ## Workflow ### Versioning and Tags -This project uses semantic versioning, expressed through the numbering scheme of +This project uses [Semantic Versioning](http://semver.org/), expressed through the numbering scheme of [PEP-0440](https://www.python.org/dev/peps/pep-0440/). ### Branches @@ -181,6 +246,10 @@ with labels. An issue should have **one** of the following labels applied: `bug` Issues are closed when a resolution has been reached. If for any reason a closed issue seems relevant once again, reopening is great and better than creating a duplicate issue. +## Managing Documentation + +See the [`/docs/README.md`](../docs/README.md) file for documentation instructions. + ## Everything else When in doubt, find the other maintainers and ask. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c2001a38d..4dcfd152a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,17 +1,23 @@ -## Summary +## Summary -(Describe the goal of this PR. Mention any related Issue numbers) + -### Category (place an `x` in each of the `[ ]`) +### Testing + + + +### Category * [ ] `slack_bolt.App` and/or its core components * [ ] `slack_bolt.async_app.AsyncApp` and/or its core components * [ ] Adapters in `slack_bolt.adapter` +* [ ] Document pages under `/docs` +* [ ] Others -## Requirements (place an `x` in each `[ ]`) +## Requirements -Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to the those rules. +Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. * [ ] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and have done my best effort to follow them. * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). -* [ ] I've run `./scripts/run_tests.sh` after making the changes. +* [ ] I've run `./scripts/install_all_and_run_tests.sh` after making the changes. diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..b2574b7cc --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +# https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuring-automatically-generated-release-notes +changelog: + categories: + - title: 🚀 Enhancements + labels: + - enhancement + - title: 🐛 Bug Fixes + labels: + - bug + - title: 📚 Documentation + labels: + - docs + - title: 🤖 Build + labels: + - build + - title: 🧪 Testing/Code Health + labels: + - code health + - title: 🔒 Security + labels: + - security + - title: 📦 Other changes + labels: + - "*" diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 000000000..dd2119b8c --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,189 @@ +name: Python CI + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +env: + LATEST_SUPPORTED_PY: "3.14" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.LATEST_SUPPORTED_PY }} + - name: Run lint verification + run: ./scripts/lint.sh + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.LATEST_SUPPORTED_PY }} + - 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 + runs-on: ubuntu-22.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: + - "3.7" + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + 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/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_dev.txt + pip install -r requirements/test_adapter.txt + - name: Run tests for HTTP Mode adapters + run: | + pytest tests/adapter_tests/ \ + --ignore=tests/adapter_tests/socket_mode/ \ + --ignore=tests/adapter_tests/asgi/ \ + --junitxml=reports/test_adapter.xml + - name: Install async dependencies + run: | + 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/test_async.txt + - name: Run tests for HTTP Mode adapters (ASGI) + run: | + # Requires async test dependencies + pytest tests/adapter_tests/asgi/ --junitxml=reports/test_adapter_asgi.xml + - name: Run tests for HTTP Mode adapters (asyncio-based libraries) + run: | + pytest tests/adapter_tests_async/ --junitxml=reports/test_adapter_async.xml + - name: Run asynchronous tests + run: | + pytest tests/slack_bolt_async/ --junitxml=reports/test_slack_bolt_async.xml + pytest tests/scenario_tests_async/ --junitxml=reports/test_scenario_async.xml + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + directory: ./reports/ + fail_ci_if_error: false + flags: ${{ matrix.python-version }} + report_type: test_results + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + codecov: + name: Code Coverage + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + env: + BOLT_PYTHON_CODECOV_RUNNING: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + 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_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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + fail_ci_if_error: true + report_type: coverage + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + notifications: + name: Regression notifications + runs-on: ubuntu-latest + needs: + - lint + - typecheck + - unittest + if: ${{ !success() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' }} + steps: + - name: Send notifications of failing tests + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + errors: true + webhook: ${{ secrets.SLACK_REGRESSION_FAILURES_WEBHOOK_URL }} + webhook-type: webhook-trigger + payload: | + action_url: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + repository: "${{ github.repository }}" diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 000000000..aafc2c0af --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,29 @@ +name: Merge updates to dependencies +on: + pull_request: +jobs: + dependabot: + name: "@dependabot" + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Collect metadata + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Approve + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Automerge + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 000000000..2d0f7630a --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,87 @@ +name: Upload A Release to pypi.org or test.pypi.org + +on: + release: + types: + - published + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (build only, do not publish)" + required: false + type: boolean + +jobs: + release-build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.x" + + - name: Build release distributions + run: | + scripts/build_pypi_package.sh + + - name: Persist dist folder + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-dist + path: dist/ + + test-pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + # Run this job for workflow_dispatch events when dry_run input is not 'true' + # Note: The comparison is against a string value 'true' since GitHub Actions inputs are strings + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' + environment: + name: testpypi + permissions: + id-token: write + + steps: + - name: Retrieve dist folder + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-dist + path: dist/ + + - name: Publish release distributions to test.pypi.org + # Using OIDC for PyPI publishing (no API tokens needed) + # See: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-pypi + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + + pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + if: github.event_name == 'release' + environment: + name: pypi + permissions: + id-token: write + + steps: + - name: Retrieve dist folder + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-dist + path: dist/ + + - name: Publish release distributions to pypi.org + # Using OIDC for PyPI publishing (no API tokens needed) + # See: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-pypi + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml new file mode 100644 index 000000000..adad546b8 --- /dev/null +++ b/.github/workflows/triage-issues.yml @@ -0,0 +1,32 @@ +# This workflow uses the following github action to automate +# management of stale issues and prs in this repo: +# https://github.com/marketplace/actions/close-stale-issues + +name: Close stale issues and PRs + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * 1" + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + days-before-issue-stale: 30 + days-before-issue-close: 10 + days-before-pr-stale: -1 + days-before-pr-close: -1 + stale-issue-label: auto-triage-stale + stale-issue-message: 👋 It looks like this issue has been open for 30 days with no activity. We'll mark this as stale for now, and wait 10 days for an update or for further comment before closing this issue out. If you think this issue needs to be prioritized, please comment to get the thread going again! Maintainers also review issues marked as stale on a regular basis and comment or adjust status if the issue needs to be reprioritized. + close-issue-message: As this issue has been inactive for more than one month, we will be closing it. Thank you to all the participants! If you would like to raise a related issue, please create a new issue which includes your specific details and references this issue number. + exempt-issue-labels: auto-triage-skip + exempt-all-milestones: true + remove-stale-when-updated: true + enable-statistics: true + operations-per-run: 60 diff --git a/.gitignore b/.gitignore index 9e80867aa..2549060e7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,11 +14,14 @@ __pycache__/ # virtualenv env*/ venv/ +.venv* +.env/ # codecov / coverage .coverage cov_* coverage.xml +reports/ # due to using tox and pytest .tox @@ -27,6 +30,7 @@ coverage.xml .python-version pip .mypy_cache/ +.ruby-version # JetBrains PyCharm settings .idea/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a0967c61b..000000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: - python -python: - - "3.6" - - "3.7" - - "3.8" -install: - - python setup.py install - - pip install -U pip - - pip install "pytest>=5,<6" - - pip install "pytype" -script: - # testing without aiohttp - - travis_retry pytest tests/scenario_tests/ - # testing with aiohttp - - pip install "pytest-asyncio<1" "aiohttp>=3,<4" - - travis_retry pytest tests/async_scenario_tests/ - # run all tests just in case - - travis_retry python setup.py test - # Run pytype only for Python 3.8 - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install -e ".[adapter]"; pytype slack_bolt/; fi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..eae86f330 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,248 @@ +# AGENTS.md - bolt-python + +## Project Overview + +Slack Bolt for Python -- a framework for building Slack apps in Python. + +- **Foundation:** Built on top of `slack_sdk` (see `pyproject.toml` constraints). +- **Execution Models:** Supports both synchronous (`App`) and asynchronous (`AsyncApp` using `asyncio`) execution. Async mode requires `aiohttp` as an additional dependency. +- **Framework Adapters:** Features built-in adapters for web frameworks (Flask, FastAPI, Django, Tornado, Pyramid, and many more) and serverless environments (AWS Lambda, Google Cloud Functions). +- **Python Version:** Requires Python 3.7+ as defined in `pyproject.toml`. + +- **Repository**: +- **Documentation**: +- **PyPI**: +- **Current version**: defined in `slack_bolt/version.py` (referenced by `pyproject.toml` via `[tool.setuptools.dynamic]`) + +## Environment Setup + +You can verify the venv is active by checking `echo $VIRTUAL_ENV`. If tools like `black`, `flake8`, `mypy` or `pytest` are not found, ask the user to activate the venv. + +A python virtual environment (`venv`) should be activated before running any commands. + +```bash +# Create a venv (first time only) +python -m venv .venv + +# Activate +source .venv/bin/activate + +# Install all dependencies +./scripts/install.sh +``` + +## Common Commands + +### Pre-submission Checklist + +Before considering any work complete, you MUST run these commands in order and confirm they all pass: + +```bash +./scripts/format.sh --no-install # 1. Format +./scripts/lint.sh --no-install # 2. Lint +./scripts/run_tests.sh # 3. Run relevant tests (see Testing below) +./scripts/run_mypy.sh --no-install # 4. Type check +``` + +To run everything at once (installs deps + formats + lints + tests + typechecks): + +```bash +./scripts/install_all_and_run_tests.sh +``` + +### Testing + +Always use the project scripts instead of calling `pytest` directly: + +```bash +# Run a single test file +./scripts/run_tests.sh tests/scenario_tests/test_app.py + +# Run a single test function +./scripts/run_tests.sh tests/scenario_tests/test_app.py::TestApp::test_name +``` + +### Formatting, Linting, Type Checking + +```bash +# Format -- Black, configured in pyproject.toml +./scripts/format.sh --no-install + +# Lint -- Flake8, configured in .flake8 +./scripts/lint.sh --no-install + +# Type check -- mypy, configured in pyproject.toml +./scripts/run_mypy.sh --no-install +``` + +## Critical Conventions + +### Sync/Async Mirroring Rule + +**When modifying any sync module, you MUST also update the corresponding async module (and vice versa).** This is the most important convention in this codebase. + +Almost every module has both a sync and async variant. Async files use the `async_` prefix alongside their sync counterpart: + +```text +slack_bolt/middleware/custom_middleware.py # sync +slack_bolt/middleware/async_custom_middleware.py # async + +slack_bolt/context/say/say.py # sync +slack_bolt/context/say/async_say.py # async + +slack_bolt/listener/custom_listener.py # sync +slack_bolt/listener/async_listener.py # async +``` + +**Modules that come in sync/async pairs:** + +- `slack_bolt/app/` -- `app.py` / `async_app.py` +- `slack_bolt/middleware/` -- every middleware has an `async_` counterpart +- `slack_bolt/listener/` -- `listener.py` / `async_listener.py`, plus error/completion/start handlers +- `slack_bolt/listener_matcher/` -- `builtins.py` / `async_builtins.py` +- `slack_bolt/context/` -- each subdirectory (e.g., `say/`, `ack/`, `respond/`) has `async_` variants +- `slack_bolt/kwargs_injection/` -- `args.py` / `async_args.py`, `utils.py` / `async_utils.py` + +**Adapters are an exception:** Most adapters are sync-only or async-only depending on the framework. Async-native frameworks (FastAPI, Starlette, Sanic, Tornado, ASGI, Socket Mode) have `async_handler.py`. Sync-only frameworks (Flask, Django, Bottle, CherryPy, Falcon, Pyramid, AWS Lambda, Google Cloud Functions, WSGI) have `handler.py`. + +### Prefer the Middleware Pattern + +Middleware is the project's preferred approach for cross-cutting concerns. Before adding logic to individual listeners or utility functions, consider whether it belongs as a built-in middleware in the framework. + +**When to add built-in middleware:** + +- Cross-cutting concerns that apply to many or all requests (logging, metrics, observability) +- Request validation, transformation, or enrichment +- Authorization extensions beyond the built-in `SingleTeamAuthorization`/`MultiTeamsAuthorization` +- Feature-level request handling (the `Assistant` middleware in `slack_bolt/middleware/assistant/assistant.py` is the canonical example -- it intercepts assistant thread events and dispatches them to registered sub-listeners) + +**How to add built-in middleware:** + +1. Subclass `Middleware` (sync) and implement `process(self, *, req, resp, next)`. Call `next()` to continue the chain. +2. Subclass `AsyncMiddleware` (async) and implement `async_process(self, *, req, resp, next)`. Call `await next()` to continue. +3. Export from `slack_bolt/middleware/__init__.py` (sync) and `slack_bolt/middleware/async_builtins.py` (async). +4. Register the middleware in `App.__init__()` (`slack_bolt/app/app.py`) and `AsyncApp.__init__()` (`slack_bolt/app/async_app.py`) where the default middleware chain is assembled. + +**Canonical example:** `AttachingFunctionToken` (`slack_bolt/middleware/attaching_function_token/`) is a good small middleware to follow -- it has a clean sync/async pair, a focused `process()` method, and is properly exported and registered in the app's middleware chain. + +### Single Runtime Dependency Rule + +The core package depends ONLY on `slack_sdk` (defined in `pyproject.toml`). Never add runtime dependencies to `pyproject.toml`. Additional dependencies go in the appropriate `requirements/*.txt` file. + +## Architecture + +### Request Processing Pipeline + +Incoming requests flow through a middleware chain before reaching listeners: + +1. **SSL Check** -> **Request Verification** (signature) -> **URL Verification** -> **Authorization** (token injection) -> **Ignoring Self Events** -> Custom middleware +2. **Listener Matching** -- `ListenerMatcher` implementations check if a listener should handle the request +3. **Listener Execution** -- listener-specific middleware runs, then `ack()` is called, then the handler executes + +For FaaS environments (`process_before_response=True`), long-running handlers execute as "lazy listeners" in a thread pool after the ack response is returned. + +### Core Abstractions + +- **`App` / `AsyncApp`** (`slack_bolt/app/`) -- Central class. Registers listeners via decorators (`@app.event()`, `@app.action()`, `@app.command()`, `@app.message()`, `@app.view()`, `@app.shortcut()`, `@app.options()`, `@app.function()`). Dispatches incoming requests through middleware to matching listeners. +- **`Middleware`** (`slack_bolt/middleware/`) -- Abstract base with `process(req, resp, next)`. Built-in: authorization, request verification, SSL check, URL verification, assistant, self-event ignoring. +- **`Listener`** (`slack_bolt/listener/`) -- Has matchers, middleware, and an ack/handler function. `CustomListener` is the main implementation. +- **`ListenerMatcher`** (`slack_bolt/listener_matcher/`) -- Determines if a listener handles a given request. Built-in matchers for events, actions, commands, messages (regex), shortcuts, views, options, functions. +- **`BoltContext`** (`slack_bolt/context/`) -- Dict-like object passed to listeners with `client`, `say()`, `ack()`, `respond()`, `complete()`, `fail()`, plus event metadata (`user_id`, `channel_id`, `team_id`, etc.). +- **`BoltRequest` / `BoltResponse`** (`slack_bolt/request/`, `slack_bolt/response/`) -- Request/response wrappers. Request has `mode` of "http" or "socket_mode". + +### Kwargs Injection + +Listeners receive arguments by parameter name. The framework inspects function signatures and injects matching args: `body`, `event`, `action`, `command`, `payload`, `context`, `client`, `ack`, `say`, `respond`, `logger`, `complete`, `fail`, etc. Defined in `slack_bolt/kwargs_injection/args.py`. + +### Adapter System + +Each adapter in `slack_bolt/adapter/` converts between a web framework's request/response types and `BoltRequest`/`BoltResponse`. Adapters exist for: Flask, FastAPI, Django, Starlette, Sanic, Bottle, Tornado, CherryPy, Falcon, Pyramid, AWS Lambda, Google Cloud Functions, Socket Mode, WSGI, ASGI, and more. + +### AI Agents & Assistants + +`Assistant` middleware (`slack_bolt/middleware/assistant/`) handles assistant thread events. + +## Key Development Patterns + +### Adding a Context Utility + +Each context utility lives in its own subdirectory under `slack_bolt/context/`: + +```text +slack_bolt/context/my_util/ + __init__.py + my_util.py # sync implementation + async_my_util.py # async implementation + internals.py # shared logic (optional) +``` + +Then wire it into `BoltContext` (`slack_bolt/context/context.py`) and `AsyncBoltContext` (`slack_bolt/context/async_context.py`). + +### Adding a New Adapter + +1. Create `slack_bolt/adapter//` +2. Add `__init__.py` and `handler.py` (or `async_handler.py` for async frameworks) +3. The handler converts the framework's request to `BoltRequest`, calls `app.dispatch()`, and converts `BoltResponse` back +4. Add the framework to `requirements/adapter_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 + +1. Add the new arg to `slack_bolt/kwargs_injection/args.py` and `async_args.py` +2. Update the `Args` class with the new property +3. Populate the arg in the appropriate context or listener setup code + +## Security Considerations + +- **Request Verification:** The built-in `RequestVerification` middleware validates `x-slack-signature` and `x-slack-request-timestamp` on every incoming HTTP request. Never disable this in production. It is automatically skipped for `socket_mode` requests. +- **Tokens & Secrets:** `SLACK_SIGNING_SECRET` and `SLACK_BOT_TOKEN` must come from environment variables. Never hardcode or commit secrets. +- **Authorization Middleware:** `SingleTeamAuthorization` and `MultiTeamsAuthorization` verify tokens and inject an authorized `WebClient` into the context. Do not bypass these. +- **Tests:** Always use mock servers (`tests/mock_web_api_server/`) and dummy values. Never use real tokens in tests. + +## Dependencies + +The core package has a **single required runtime dependency**: `slack_sdk` (defined in `pyproject.toml`). Do not add runtime dependencies. + +**`requirements/` directory structure:** + +- `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 + +- `tests/scenario_tests/` -- Integration-style tests with realistic Slack payloads +- `tests/slack_bolt/` -- Unit tests mirroring the source structure +- `tests/adapter_tests/` and `tests/adapter_tests_async/` -- Framework adapter tests +- `tests/mock_web_api_server/` -- Mock Slack API server used by tests +- Async test variants use `_async` suffix directories + +**Where to put new tests:** Mirror the source structure. For `slack_bolt/middleware/foo.py`, add tests in `tests/slack_bolt/middleware/test_foo.py`. For async variants, use the `_async` suffix directory or file naming pattern. Adapter tests go in `tests/adapter_tests/` (sync) or `tests/adapter_tests_async/` (async). + +**Mock server:** Many tests use `tests/mock_web_api_server/` to simulate Slack API responses. Look at existing tests for usage patterns rather than making real API calls. + +### CI Pipeline + +GitHub Actions (`.github/workflows/ci-build.yml`) runs on every push to `main` and every PR: + +- **Lint** -- `./scripts/lint.sh` on latest Python +- **Typecheck** -- `./scripts/run_mypy.sh` on latest Python +- **Unit tests** -- full test suite across Python 3.7--3.14 matrix +- **Code coverage** -- uploaded to Codecov + +## PR and Commit Guidelines + +- PRs target the `main` branch +- You MUST run `./scripts/install_all_and_run_tests.sh` before submitting +- PR template (`.github/pull_request_template.md`) requires: Summary, Testing steps, Category checkboxes (`App`, `AsyncApp`, Adapters, Docs, Others) +- Requirements: CLA signed, test suite passes, code review approval +- Commits should be atomic with descriptive messages. Reference related issue numbers. diff --git a/LICENSE b/LICENSE index dfbeb4a96..f2e8442ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020- Slack Technologies, Inc +Copyright (c) 2020- Slack Technologies, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2894610f6..39747df40 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,35 @@ -# ⚠️ Important Notice ⚠️ - -## 🔄 Still Work In Progress 🔄 - -This project is **still in alpha**, and may have bugs in it. Also, the public APIs can be changed until the v1 release. We are keen to hear your feedback. Please feel free to [submit an issue](https://github.com/slackapi/bolt-python/issues)! - -# Bolt for Python (still in alpha) - -[![Python Version][python-version]][pypi-url] -[![pypi package][pypi-image]][pypi-url] -[![Build Status][travis-image]][travis-url] - -A Python framework to build Slack apps in a flash with the latest platform features. Check the [samples](https://github.com/slackapi/bolt-python/tree/main/samples) to know how to use this framework. +

Bolt Bolt logo for Python

+ +

+ + PyPI - Version + + Codecov + + Pepy Total Downloads +
+ + Python Versions + + Documentation +

+ +A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://docs.slack.dev/tools/bolt-python/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. The Python module documents are available [here](https://docs.slack.dev/tools/bolt-python/reference/). ## Setup ```bash -python -m venv env -source env/bin/activate +# Python 3.7+ required +python -m venv .venv +source .venv/bin/activate + +pip install -U pip pip install slack_bolt ``` -## First Bolt App (app.py) +## Creating an app -Create an app by calling a constructor, which is a top-level export. +Create a Bolt for Python app by calling a constructor, which is a top-level export. If you'd prefer, you can create an [async app](#creating-an-async-app). ```python import logging @@ -34,65 +41,13 @@ from slack_bolt import App # export SLACK_BOT_TOKEN=xoxb-*** app = App() -# Middleware -@app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.info(payload) - return next() - -# Events API: https://api.slack.com/events-api -@app.event("app_mention") -def event_test(say): - say("What's up?") - -# Interactivity: https://api.slack.com/interactivity -@app.shortcut("callback-id-here") -# @app.command("/hello-bolt-python") -def open_modal(ack, client, logger, payload): - # acknowledge the incoming request from Slack immediately - ack() - # open a modal - api_response = client.views_open( - trigger_id=payload["trigger_id"], - view={ - "type": "modal", - "callback_id": "view-id", - "title": { - "type": "plain_text", - "text": "My App", - }, - "submit": { - "type": "plain_text", - "text": "Submit", - }, - "blocks": [ - { - "type": "input", - "block_id": "b", - "element": { - "type": "plain_text_input", - "action_id": "a" - }, - "label": { - "type": "plain_text", - "text": "Label", - } - } - ] - }) - logger.debug(api_response) - -@app.view("view-id") -def view_submission(ack, payload, logger): - ack() - # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(payload["view"]["state"]["values"]) +# Add functionality here if __name__ == "__main__": app.start(3000) # POST http://localhost:3000/slack/events ``` -## Run the Bolt App +## Running an app ```bash export SLACK_SIGNING_SECRET=*** @@ -103,16 +58,150 @@ python app.py ngrok http 3000 ``` -# Feedback +## Running a Socket Mode app + +If you use [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode/) for running your app, `SocketModeHandler` is available for it. + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Install the Slack app and get xoxb- token in advance +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + +# Add functionality here + +if __name__ == "__main__": + # Create an app-level token with connections:write scope + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + handler.start() +``` + +Run the app this way: + +```bash +export SLACK_APP_TOKEN=xapp-*** +export SLACK_BOT_TOKEN=xoxb-*** +python app.py + +# SLACK_SIGNING_SECRET is not required +# Running ngrok is not required +``` + +## Listening for events + +Apps typically react to a collection of incoming events, which can correspond to [Events API events](https://docs.slack.dev/apis/events-api/), [actions](https://docs.slack.dev/block-kit/#making-things-interactive), [shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/), [slash commands](https://docs.slack.dev/interactivity/implementing-slash-commands/) or [options requests](https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select). For each type of +request, there's a method to build a listener function. + +```python +# Listen for an action from a Block Kit element (buttons, select menus, date pickers, etc) +app.action(action_id)(fn) + +# Listen for dialog submissions +app.action({"callback_id": callbackId})(fn) + +# Listen for slash commands +app.command(command_name)(fn) + +# Listen for an event from the Events API +app.event(event_type)(fn) + +# Listen for a custom step execution from a workflow +app.function(callback_id)(fn) + +# Convenience method to listen to only `message` events using a string or re.Pattern +app.message([pattern ,])(fn) + +# Listen for options requests (from select menus with an external data source) +app.options(action_id)(fn) + +# Listen for a global or message shortcuts +app.shortcut(callback_id)(fn) + +# Listen for view_submission modal events +app.view(callback_id)(fn) +``` + +The recommended way to use these methods are decorators: + +```python +@app.event(event_type) +def handle_event(event): + pass +``` + +## Making things happen + +Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments, each of which can be used in any order. If you'd like to access arguments off of a single object, you can use `args`, an [`slack_bolt.kwargs_injection.Args`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/kwargs_injection/args.py) instance that contains all available arguments for that event. + +| Argument | Description | +| :---: | :--- | +| `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authorizations`). +| `payload` | Contents of the incoming event. The payload structure depends on the listener. For example, for an Events API event, `payload` will be the [event type structure](https://docs.slack.dev/apis/events-api/#event-type-structure). For a block action, it will be the action from within the `actions` list. The `payload` dictionary is also accessible via the alias corresponding to the listener (`message`, `event`, `action`, `shortcut`, `view`, `command`, or `options`). For example, if you were building a `message()` listener, you could use the `payload` and `message` arguments interchangably. **An easy way to understand what's in a payload is to log it**. | +| `context` | Event context. This dictionary contains data about the event and app, such as the `botId`. Middleware can add additional context before the event is passed to listeners. +| `ack` | Function that **must** be called to acknowledge that your app received the incoming event. `ack` exists for all actions, shortcuts, view submissions, slash command and options requests. `ack` returns a promise that resolves when complete. Read more in [Acknowledging events](https://docs.slack.dev/tools/bolt-python/concepts/acknowledge/). +| `respond` | Utility function that responds to incoming events **if** it contains a `response_url` (shortcuts, actions, and slash commands). +| `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). +| `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth/), or manually using the `authorize` function. +| `logger` | The built-in [`logging.Logger`](https://docs.python.org/3/library/logging.html) instance you can use in middleware/listeners. +| `complete` | Utility function used to signal the successful completion of a custom step execution. This tells Slack to proceed with the next steps in the workflow. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. +| `fail` | Utility function used to signal that a custom step failed to complete. This tells Slack to stop the workflow execution. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. + +## Creating an async app + +If you'd prefer to build your app with [asyncio](https://docs.python.org/3/library/asyncio.html), you can import the [AIOHTTP](https://docs.aiohttp.org/en/stable/) library and call the `AsyncApp` constructor. Within async apps, you can use the async/await pattern. + +```bash +# Python 3.7+ required +python -m venv .venv +source .venv/bin/activate + +pip install -U pip +# aiohttp is required +pip install slack_bolt aiohttp +``` + +In async apps, all middleware/listeners must be async functions. When calling utility methods (like `ack` and `say`) within these functions, it's required to use the `await` keyword. + +```python +# Import the async app instead of the regular one +from slack_bolt.async_app import AsyncApp + +app = AsyncApp() + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + +@app.command("/hello-bolt-python") +async def command(ack, body, respond): + await ack() + await respond(f"Hi <@{body['user_id']}>!") + +if __name__ == "__main__": + app.start(3000) +``` + +If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at the built-in adapters and their examples. + +* [The Bolt app examples](https://github.com/slackapi/bolt-python/tree/main/examples) +* [The built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) +Apps can be run the same way as the syncronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [examples](https://github.com/slackapi/bolt-python/tree/main/examples). + +## Getting Help + +[The documentation](https://docs.slack.dev/tools/bolt-python/) has more information on basic and advanced concepts for Bolt for Python. Also, all the Python module documents of this library are available [here](https://docs.slack.dev/tools/bolt-python/reference/). -We are keen to hear your feedback. Please feel free to [submit an issue](https://github.com/slackapi/bolt-python/issues)! +If you otherwise get stuck, we're here to help. The following are the best ways to get assistance working through your issue: -# License + * [Issue Tracker](http://github.com/slackapi/bolt-python/issues) for questions, bug reports, feature requests, and general discussion related to Bolt for Python. Try searching for an existing issue before creating a new one. + * [Email](mailto:support@slack.com) our developer support team: `support@slack.com` -The MIT License [pypi-image]: https://badge.fury.io/py/slack-bolt.svg [pypi-url]: https://pypi.org/project/slack-bolt/ -[travis-image]: https://travis-ci.org/slackapi/bolt-python.svg?branch=main -[travis-url]: https://travis-ci.org/slackapi/bolt-python +[codecov-image]: https://codecov.io/gh/slackapi/bolt-python/branch/main/graph/badge.svg +[codecov-url]: https://codecov.io/gh/slackapi/bolt-python [python-version]: https://img.shields.io/pypi/pyversions/slack-bolt.svg 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/codecov.yml b/codecov.yml new file mode 100644 index 000000000..5568e5e6b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + threshold: 2.0% + patch: + default: + target: 50% diff --git a/docs/english/_sidebar.json b/docs/english/_sidebar.json new file mode 100644 index 000000000..aa75b0c15 --- /dev/null +++ b/docs/english/_sidebar.json @@ -0,0 +1,206 @@ +[ + { + "type": "doc", + "id": "tools/bolt-python/index", + "label": "Bolt for Python", + "className": "sidebar-title" + }, + "tools/bolt-python/getting-started", + { "type": "html", "value": "
" }, + "tools/bolt-python/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", + "items": [ + "tools/bolt-python/concepts/message-sending", + "tools/bolt-python/concepts/web-api" + ] + }, + { + "type": "category", + "label": "Events", + "items": [ + "tools/bolt-python/concepts/message-listening", + "tools/bolt-python/concepts/event-listening" + ] + }, + { + "type": "category", + "label": "App UI & Interactivity", + "items": [ + "tools/bolt-python/concepts/acknowledge", + "tools/bolt-python/concepts/shortcuts", + "tools/bolt-python/concepts/commands", + "tools/bolt-python/concepts/actions", + "tools/bolt-python/concepts/opening-modals", + "tools/bolt-python/concepts/updating-pushing-views", + "tools/bolt-python/concepts/view-submissions", + "tools/bolt-python/concepts/select-menu-options", + "tools/bolt-python/concepts/app-home" + ] + }, + { + "type": "category", + "label": "Custom Steps", + "items": [ + "tools/bolt-python/concepts/custom-steps", + "tools/bolt-python/concepts/custom-steps-dynamic-options" + ] + }, + { + "type": "category", + "label": "App Configuration", + "items": [ + "tools/bolt-python/concepts/socket-mode", + "tools/bolt-python/concepts/errors", + "tools/bolt-python/concepts/logging", + "tools/bolt-python/concepts/async" + ] + }, + { + "type": "category", + "label": "Middleware & Context", + "items": [ + "tools/bolt-python/concepts/global-middleware", + "tools/bolt-python/concepts/listener-middleware", + "tools/bolt-python/concepts/context" + ] + }, + "tools/bolt-python/concepts/lazy-listeners", + { + "type": "category", + "label": "Adaptors", + "items": [ + "tools/bolt-python/concepts/adapters", + "tools/bolt-python/concepts/custom-adapters" + ] + }, + { + "type": "category", + "label": "Authorization & Security", + "items": [ + "tools/bolt-python/concepts/authenticating-oauth", + "tools/bolt-python/concepts/authorization", + "tools/bolt-python/concepts/token-rotation" + ] + }, + "tools/bolt-python/experiments", + { + "type": "category", + "label": "Legacy", + "items": ["tools/bolt-python/legacy/steps-from-apps"] + }, + { "type": "html", "value": "
" }, + { + "type": "category", + "label": "Tutorials", + "items": [ + "tools/bolt-python/tutorial/ai-chatbot/ai-chatbot", + "tools/bolt-python/tutorial/order-confirmation/order-confirmation", + "tools/bolt-python/tutorial/custom-steps", + "tools/bolt-python/tutorial/custom-steps-for-jira/custom-steps-for-jira", + "tools/bolt-python/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new", + "tools/bolt-python/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing", + "tools/bolt-python/tutorial/modals/modals" + ] + }, + { "type": "html", "value": "
" }, + { + "type": "link", + "label": "Reference", + "href": "https://docs.slack.dev/tools/bolt-python/reference/index.html" + }, + { "type": "html", "value": "
" }, + { + "type": "category", + "label": "日本語 (日本)", + "items": [ + "tools/bolt-python/ja-jp/getting-started", + { + "type": "category", + "label": "Slack API コール", + "items": [ + "tools/bolt-python/ja-jp/concepts/message-sending", + "tools/bolt-python/ja-jp/concepts/web-api" + ] + }, + { + "type": "category", + "label": "イベント API", + "items": [ + "tools/bolt-python/ja-jp/concepts/message-listening", + "tools/bolt-python/ja-jp/concepts/event-listening" + ] + }, + { + "type": "category", + "label": "インタラクティビティ & ショートカット", + "items": [ + "tools/bolt-python/ja-jp/concepts/acknowledge", + "tools/bolt-python/ja-jp/concepts/shortcuts", + "tools/bolt-python/ja-jp/concepts/commands", + "tools/bolt-python/ja-jp/concepts/actions", + "tools/bolt-python/ja-jp/concepts/opening-modals", + "tools/bolt-python/ja-jp/concepts/updating-pushing-views", + "tools/bolt-python/ja-jp/concepts/view-submissions", + "tools/bolt-python/ja-jp/concepts/select-menu-options", + "tools/bolt-python/ja-jp/concepts/app-home" + ] + }, + { + "type": "category", + "label": "App の設定", + "items": [ + "tools/bolt-python/ja-jp/concepts/socket-mode", + "tools/bolt-python/ja-jp/concepts/errors", + "tools/bolt-python/ja-jp/concepts/logging", + "tools/bolt-python/ja-jp/concepts/async" + ] + }, + { + "type": "category", + "label": "ミドルウェア & コンテキスト", + "items": [ + "tools/bolt-python/ja-jp/concepts/global-middleware", + "tools/bolt-python/ja-jp/concepts/listener-middleware", + "tools/bolt-python/ja-jp/concepts/context" + ] + }, + "tools/bolt-python/ja-jp/concepts/lazy-listeners", + { + "type": "category", + "label": "アダプター", + "items": [ + "tools/bolt-python/ja-jp/concepts/adapters", + "tools/bolt-python/ja-jp/concepts/custom-adapters" + ] + }, + { + "type": "category", + "label": "認可 & セキュリティ", + "items": [ + "tools/bolt-python/ja-jp/concepts/authenticating-oauth", + "tools/bolt-python/ja-jp/concepts/authorization", + "tools/bolt-python/ja-jp/concepts/token-rotation" + ] + }, + { + "type": "category", + "label": "レガシー(非推奨)", + "items": ["tools/bolt-python/ja-jp/legacy/steps-from-apps"] + } + ] + } +] diff --git a/docs/english/concepts/acknowledge.md b/docs/english/concepts/acknowledge.md new file mode 100644 index 000000000..57b346bd3 --- /dev/null +++ b/docs/english/concepts/acknowledge.md @@ -0,0 +1,32 @@ +# Acknowledging requests + +Actions, commands, shortcuts, options requests, and view submissions must **always** be acknowledged using the `ack()` function. This lets Slack know that the request was received so that it may update the Slack user interface accordingly. + +Depending on the type of request, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](/reference/block-kit/composition-objects/option-object/). When acknowledging a view submission, you may supply a `response_action` as part of your acknowledgement to [update the view](/tools/bolt-python/concepts/view-submissions). + +We recommend calling `ack()` right away before initiating any time-consuming processes such as fetching information from your database or sending a new message, since you only have 3 seconds to respond before Slack registers a timeout error. + +:::info[When working in a FaaS / serverless environment, our guidelines for when to `ack()` are different. See the section on [Lazy listeners (FaaS)](/tools/bolt-python/concepts/lazy-listeners) for more detail on this.] + +::: + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + +```python +# Example of responding to an external_select options request +@app.options("menu_selection") +def show_menu_options(ack): + options = [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ] + ack(options=options) +``` diff --git a/docs/english/concepts/actions.md b/docs/english/concepts/actions.md new file mode 100644 index 000000000..d7dfa6ba1 --- /dev/null +++ b/docs/english/concepts/actions.md @@ -0,0 +1,69 @@ +# Listening & responding to actions + +Your app can listen and respond to user actions, like button clicks, and menu selects, using the `action` method. + +## Listening to actions + +Actions can be filtered on an `action_id` parameter of type `str` or `re.Pattern`. The `action_id` parameter acts as a unique identifier for interactive components on the Slack platform. + +You'll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the request was received from Slack. This is discussed in the [acknowledging requests guide](/tools/bolt-python/concepts/acknowledge). + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +```python +# Your listener will be called every time a block element with the action_id "approve_button" is triggered +@app.action("approve_button") +def update_message(ack): + ack() + # Update the message to reflect the action +``` + +### Listening to actions using a constraint object + +You can use a constraints object to listen to `block_id`s and `action_id`s (or any combination of them). Constraints in the object can be of type `str` or `re.Pattern`. + +```python +# Your function will only be called when the action_id matches 'select_user' AND the block_id matches 'assign_ticket' +@app.action({ + "block_id": "assign_ticket", + "action_id": "select_user" +}) +def update_message(ack, body, client): + ack() + + if "container" in body and "message_ts" in body["container"]: + client.reactions_add( + name="white_check_mark", + channel=body["channel"]["id"], + timestamp=body["container"]["message_ts"], + ) +``` + +## Responding to actions + +There are two main ways to respond to actions. The first (and most common) way is to use `say()`, which sends a message back to the conversation where the incoming request took place. + +The second way to respond to actions is using `respond()`, which is a utility to use the `response_url` associated with the action. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +```python +# Your listener will be called every time an interactive component with the action_id “approve_button” is triggered +@app.action("approve_button") +def approve_request(ack, say): + # Acknowledge action request + ack() + say("Request approved 👍") +``` + +### Using `respond()` method + +Since `respond()` is a utility for calling the `response_url`, it behaves in the same way. You can pass [all the message payload properties](/messaging/#payloads) as keyword arguments along with optional properties like `response_type` (which has a value of `"in_channel"` or `"ephemeral"`), `replace_original`, `delete_original`, `unfurl_links`, and `unfurl_media`. With that, your app can send a new message payload that will be published back to the source of the original interaction. + +```python +# Listens to actions triggered with action_id of “user_select” +@app.action("user_select") +def select_user(ack, action, respond): + ack() + respond(f"You selected <@{action['selected_user']}>") +``` \ No newline at end of file diff --git a/docs/english/concepts/adapters.md b/docs/english/concepts/adapters.md new file mode 100644 index 000000000..ad43a27da --- /dev/null +++ b/docs/english/concepts/adapters.md @@ -0,0 +1,40 @@ +# Adapters + +Adapters are responsible for handling and parsing incoming requests from Slack to conform to [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py), then dispatching those requests to your Bolt app. + +By default, Bolt will use the built-in [`HTTPServer`](https://docs.python.org/3/library/http.server.html) adapter. While this is okay for local development, **it is not recommended for production**. Bolt for Python includes a collection of built-in adapters that can be imported and used with your app. The built-in adapters support a variety of popular Python frameworks including Flask, Django, and Starlette among others. Adapters support the use of any production-ready web server of your choice. + +To use an adapter, you'll create an app with the framework of your choosing and import its corresponding adapter. Then you'll initialize the adapter instance and call its function that handles and parses incoming requests. + +The full list adapters, as well as configuration and sample usage, can be found within the repository's [`examples`](https://github.com/slackapi/bolt-python/tree/main/examples) + +## Example + +```python +from slack_bolt import App +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + token=os.environ.get("SLACK_BOT_TOKEN") +) + +# There is nothing specific to Flask here! +# App is completely framework/runtime agnostic +@app.command("/hello-bolt") +def hello(body, ack): + ack(f"Hi <@{body['user_id']}>!") + +# Initialize Flask app +from flask import Flask, request +flask_app = Flask(__name__) + +# SlackRequestHandler translates WSGI requests to Bolt's interface +# and builds WSGI response from Bolt's response. +from slack_bolt.adapter.flask import SlackRequestHandler +handler = SlackRequestHandler(app) + +# Register routes to Flask app +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + # handler runs App's dispatch method + return handler.handle(request) +``` diff --git a/docs/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/app-home.md b/docs/english/concepts/app-home.md new file mode 100644 index 000000000..f4f15337f --- /dev/null +++ b/docs/english/concepts/app-home.md @@ -0,0 +1,42 @@ +# Publishing views to App Home + +[Home tabs](/surfaces/app-home) are customizable surfaces accessible via the sidebar and search that allow apps to display views on a per-user basis. After enabling App Home within your app configuration, home tabs can be published and updated by passing a `user_id` and [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission) to the [`views.publish`](/reference/methods/views.publish) method. + +You can subscribe to the [`app_home_opened`](/reference/events/app_home_opened) event to listen for when users open your App Home. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + +```python +@app.event("app_home_opened") +def update_home_tab(client, event, logger): + try: + # Call views.publish with the built-in client + client.views_publish( + # Use the user ID associated with the event + user_id=event["user"], + # Home tabs must be enabled in your app configuration + view={ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Welcome home, <@" + event["user"] + "> :house:*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Learn how home tabs can be more useful and interactive ." + } + } + ] + } + ) + except Exception as e: + logger.error(f"Error publishing home tab: {e}") +``` \ No newline at end of file diff --git a/docs/english/concepts/async.md b/docs/english/concepts/async.md new file mode 100644 index 000000000..e6ae28fc6 --- /dev/null +++ b/docs/english/concepts/async.md @@ -0,0 +1,67 @@ +# Using async (asyncio) + +To use the async version of Bolt, you can import and initialize an `AsyncApp` instance (rather than `App`). `AsyncApp` relies on [AIOHTTP](https://docs.aiohttp.org) to make API requests, which means you'll need to install `aiohttp` (by adding to `requirements.txt` or running `pip install aiohttp`). + +Sample async projects can be found within the repository's [examples](https://github.com/slackapi/bolt-python/tree/main/examples) folder. + +```python +# Requirement: install aiohttp +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +@app.event("app_mention") +async def handle_mentions(event, client, say): # async function + api_response = await client.reactions_add( + channel=event["channel"], + timestamp=event["ts"], + name="eyes", + ) + await say("What's up?") + +if __name__ == "__main__": + app.start(3000) +``` + +## Using other frameworks + +Internally `AsyncApp#start()` implements a [`AIOHTTP`](https://docs.aiohttp.org/) web server. If you prefer, you can use a framework other than `AIOHTTP` to handle incoming requests. + +This example uses [Sanic](https://sanicframework.org/), but the full list of adapters are in the [`adapter` folder](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). + +The following commands install the necessary requirements and starts the Sanic server on port 3000. + +```bash +# Install requirements +pip install slack_bolt sanic uvicorn +# Save the source as async_app.py +uvicorn async_app:api --reload --port 3000 --log-level debug +``` + +```python +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +# There is nothing specific to Sanic here! +# AsyncApp is completely framework/runtime agnostic +@app.event("app_mention") +async def handle_app_mentions(say): + await say("What's up?") + +import os +from sanic import Sanic +from sanic.request import Request +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +# Create an adapter for Sanic with the App instance +app_handler = AsyncSlackRequestHandler(app) +# Create a Sanic app +api = Sanic(name="awesome-slack-app") + +@api.post("/slack/events") +async def endpoint(req: Request): + # app_handler internally runs the App's dispatch method + return await app_handler.handle(req) + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) +``` \ No newline at end of file diff --git a/docs/english/concepts/authenticating-oauth.md b/docs/english/concepts/authenticating-oauth.md new file mode 100644 index 000000000..4ce9b205b --- /dev/null +++ b/docs/english/concepts/authenticating-oauth.md @@ -0,0 +1,86 @@ +# Authenticating with OAuth + +Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If you're implementing a custom adapter, you can make use of our [OAuth library](/tools/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. + +Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app's installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. + +Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. + +Bolt for Python automatically includes support for [org wide installations](/enterprise) in version `1.1.0+`. Org wide installations can be enabled in your app configuration settings under **Org Level Apps**. + +To learn more about the OAuth installation flow with Slack, [read the API documentation](/authentication/installing-with-oauth). + +```python +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +oauth_settings = OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + scopes=["channels:read", "groups:read", "chat:write"], + installation_store=FileInstallationStore(base_dir="./data/installations"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states") +) + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + oauth_settings=oauth_settings +) +``` + +## Customizing OAuth defaults + +You can override the default OAuth using `oauth_settings`, which can be passed in during the initialization of App. You can override the following: + +- `install_path`: Override default path for "Add to Slack" button +- `redirect_uri`: Override default redirect url path +- `callback_options`: Provide custom success and failure pages at the end of the OAuth flow +- `state_store`: Provide a custom state store instead of using the built in `FileOAuthStateStore` +- `installation_store`: Provide a custom installation store instead of the built-in `FileInstallationStore` + +```python +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.response import BoltResponse + +def success(args: SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse( + status=200, # you can redirect users too + body="Your own response to end-users here" + ) + +def failure(args: FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse( + status=args.suggested_status_code, + body="Your own response to end-users here" + ) + +callback_options = CallbackOptions(success=success, failure=failure) + +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + installation_store=FileInstallationStore(base_dir="./data/installations"), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["app_mentions:read", "channels:history", "im:history", "chat:write"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states"), + callback_options=callback_options, + ), +) +``` \ No newline at end of file diff --git a/docs/english/concepts/authorization.md b/docs/english/concepts/authorization.md new file mode 100644 index 000000000..9d0086ca7 --- /dev/null +++ b/docs/english/concepts/authorization.md @@ -0,0 +1,73 @@ +# Authorization + +Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack request. + +Apps installed on a single workspace can 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: + +* 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. +- **`bot_user_id`** and **`bot_id`**, if using a `bot_token`. +- **`enterprise_id`** and **`team_id`**, which can be found in requests sent to your app. +- **`user_id`** only when using `user_token`. + +## Example + +```python +import os +from slack_bolt import App +# Import the AuthorizeResult class +from slack_bolt.authorization import AuthorizeResult + +# This is just an example (assumes there are no user tokens) +# You should store authorizations in a secure DB +installations = [ + { + "enterprise_id": "E1234A12AB", + "team_id": "T12345", + "bot_token": "xoxb-123abc", + "bot_id": "B1251", + "bot_user_id": "U12385" + }, + { + "team_id": "T77712", + "bot_token": "xoxb-102anc", + "bot_id": "B5910", + "bot_user_id": "U1239", + "enterprise_id": "E1234A12AB" + } +] + +def authorize(enterprise_id, team_id, logger): + # You can implement your own logic to fetch token here + for team in installations: + # enterprise_id doesn't exist for some teams + is_valid_enterprise = "enterprise_id" not in team or enterprise_id == team["enterprise_id"] + if is_valid_enterprise and team["team_id"] == team_id: + # Return an instance of AuthorizeResult + # If you don't store bot_id and bot_user_id, could also call `from_auth_test_response` with your bot_token to automatically fetch them + return AuthorizeResult( + enterprise_id=enterprise_id, + team_id=team_id, + bot_token=team["bot_token"], + bot_id=team["bot_id"], + bot_user_id=team["bot_user_id"] + ) + + logger.error("No authorization information was found") + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + authorize=authorize +) +``` + +## 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/commands.md b/docs/english/concepts/commands.md new file mode 100644 index 000000000..cd772c57b --- /dev/null +++ b/docs/english/concepts/commands.md @@ -0,0 +1,22 @@ +# Listening & responding to commands + +Your app can use the `command()` method to listen to incoming slash command requests. The method requires a `command_name` of type `str`. + +Commands must be acknowledged with `ack()` to inform Slack your app has received the request. + +There are two ways to respond to slash commands. The first way is to use `say()`, which accepts a string or JSON payload. The second is `respond()` which is a utility for the `response_url`. These are explained in more depth in the [responding to actions](/tools/bolt-python/concepts/actions) section. + +When setting up commands within your app configuration, you'll append `/slack/events` to your request URL. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + +```python +# The echo command simply echoes on command +@app.command("/echo") +def repeat_text(ack, respond, command): + # Acknowledge command request + ack() + respond(f"{command['text']}") +``` diff --git a/docs/english/concepts/context.md b/docs/english/concepts/context.md new file mode 100644 index 000000000..46684ea28 --- /dev/null +++ b/docs/english/concepts/context.md @@ -0,0 +1,66 @@ +# Adding context + +All listeners have access to a `context` dictionary, which can be used to enrich requests with additional information. Bolt automatically attaches information that is included in the incoming request, like `user_id`, `team_id`, `channel_id`, and `enterprise_id`. + +`context` is just a dictionary, so you can directly modify it. + +## Example + +```python +# Listener middleware to fetch tasks from external system using user ID +def fetch_tasks(context, event, next): + user = event["user"] + try: + # Assume get_tasks fetchs list of tasks from DB corresponding to user ID + user_tasks = db.get_tasks(user) + tasks = user_tasks + except Exception: + # get_tasks() raises exception because no tasks are found + tasks = [] + finally: + # Put user's tasks in context + context["tasks"] = tasks + next() + +# Listener middleware to create a list of section blocks +def create_sections(context, next): + task_blocks = [] + # Loops through tasks added to context in previous middleware + for task in context["tasks"]: + task_blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{task['title']}*\n{task['body']}" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "See task" + }, + "url": task["url"], + } + } + ) + # Put list of blocks in context + context["blocks"] = task_blocks + next() + +# Listen for user opening app home +# Include fetch_tasks middleware +@app.event( + event = "app_home_opened", + middleware = [fetch_tasks, create_sections] +) +def show_tasks(event, client, context): + # Publish view to user's home tab + client.views_publish( + user_id=event["user"], + view={ + "type": "home", + "blocks": context["blocks"] + } + ) +``` diff --git a/docs/english/concepts/custom-adapters.md b/docs/english/concepts/custom-adapters.md new file mode 100644 index 000000000..21f7f33e0 --- /dev/null +++ b/docs/english/concepts/custom-adapters.md @@ -0,0 +1,67 @@ +# Custom adapters + +[Adapters](/tools/bolt-python/concepts/adapters) are flexible and can be adjusted based on the framework you prefer. There are two necessary components of adapters: + +- `__init__(app: App)`: Constructor that accepts and stores an instance of the Bolt `App`. +- `handle(req: Request)`: Function (typically named `handle()`) that receives incoming Slack requests, parses them to conform to an instance of [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py), then dispatches them to the stored Bolt app. + +`BoltRequest` instantiation accepts four parameters: + +| Parameter | Description | Required? | +|-----------|-------------|-----------| +| `body: str` | The raw request body | **Yes** | +| `query: any` | The query string data | No | +| `headers: Dict[str, Union[str, List[str]]]` | Request headers | No | +| `context: BoltContext` | Any context for the request | No | + +Your adapter will return [an instance of `BoltResponse`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py) from the Bolt app. + +For more in-depth examples of custom adapters, look at the implementations of the [built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). + +## Example + +```python +# Necessary imports for Flask +from flask import Request, Response, make_response + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + +# This example is a simplified version of the Flask adapter +# For a more detailed, complete example, look in the adapter folder +# github.com/slackapi/bolt-python/blob/main/slack_bolt/adapter/flask/handler.py + +# Takes in an HTTP request and converts it to a standard BoltRequest +def to_bolt_request(req: Request) -> BoltRequest: + return BoltRequest( + body=req.get_data(as_text=True), + query=req.query_string.decode("utf-8"), + headers=req.headers, + ) + +# Takes in a BoltResponse and converts it to a standard Flask response +def to_flask_response(bolt_resp: BoltResponse) -> Response: + resp: Response = make_response(bolt_resp.body, bolt_resp.status) + for k, values in bolt_resp.headers.items(): + for v in values: + resp.headers.add_header(k, v) + return resp + +# Instantiated from your app +# Accepts a Flask app +class SlackRequestHandler: + def __init__(self, app: App): + self.app = app + + # handle() will be called from your Flask app + # when you receive a request from Slack + def handle(self, req: Request) -> Response: + # This example does not cover OAuth + if req.method == "POST": + # Dispatch the request for Bolt to handle and route + bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) +``` diff --git a/docs/english/concepts/custom-steps-dynamic-options.md b/docs/english/concepts/custom-steps-dynamic-options.md new file mode 100644 index 000000000..9a152daa0 --- /dev/null +++ b/docs/english/concepts/custom-steps-dynamic-options.md @@ -0,0 +1,247 @@ +# Custom Steps dynamic options for Workflow Builder + +## Background {#background} + +[Legacy steps from apps](/changelog/2023-08-workflow-steps-from-apps-step-back) previously enabled Slack apps to create and process custom workflow steps, which could then be shared and used by anyone in Workflow Builder. To support your transition away from them, custom steps used as dynamic options are available. These allow you to use data defined when referencing the step in Workflow Builder as inputs to the step. + +## Example use case {#use-case} + +Let's say a builder wants to add a custom step in Workflow Builder that creates an issue in an external issue-tracking system. First, they'll need to specify a project. Once a project is selected, a project-specific list of fields can be presented to them to choose from when creating the issue. + +As a developer, dynamic options allow you to supply data to input parameters of custom steps so that you can provide builders with varying sets of fields based on the builders' selections. + +In this example, the primary step would invoke a separate project selection step that retrieves the list of available projects. The builder-selected item from the retrieved list would then be used as the input to the secondary issue creation step. + +There are two parts necessary for Slack apps to support dynamic options: custom step definitions, and handling custom step dynamic options. We'll take a look at both in the following sections. + +## Custom step definitions {#custom-step-definitions} + +When defining an input to a custom step intended to be dynamic (rather than explicitly defining a set of input parameters up front), you'll define a `dynamic_options` property that points to another custom step designed to return the set of dynamic elements once this step is added to a workflow from Workflow Builder. + +An input parameter for a custom step can reference a different custom step that defines what data is available for it to return. One Slack app could even use another Slack app’s custom step to define dynamic options for one of its inputs. + +The following code snippet from our issue creation example discussed above shows a `create-issue` custom step that will be used as a workflow step. Another custom step, the `get-projects` step, will dynamically populate the project input parameter to be configured by a builder. This `get-projects` step provides an `array` containing projects fetched dynamically from the external issue-tracking system. + +```js + "functions": { + "create-issue": { + "title": "Create Issue", + "description": "", + "input_parameters": { + "support_channel": { + "type": "slack#/types/channel_id", + "title": "Support Channel", + "description": "", + "name": "support_channel" + }, + "project": { + "type": "string", + "title": "Project", + "description": "A project from the issue tracking system", + "is_required": true, + "dynamic_options": { + "function": "#/functions/get-projects", + "inputs": {} + } + }, + }, + "output_parameters": {} + }, + "get-projects": { + "title": "Get Projects", + "description": "Get the available project from the issue tracking system", + "input_parameters": {}, + "output_parameters": { + "options": { + "type": "slack#/types/options_select", + "title": "Project Options", + } + } + } + }, +``` +### Defining the `function` and `inputs` attributes {#define-attributes} + +Defining the `function` and `inputs` attributes of the `dynamic_options` property would look as follows: + +``` +"dynamic_options": { + "function": "#/functions/get-projects", + "inputs": {} +} +``` + +The `function` attribute specifies the step reference used to resolve the options of the input parameter. For example: `"#/functions/get-projects"`. + +The `inputs` attribute defines the parameters to be passed as inputs to the step referenced by the `function` attribute. For example: + +``` +"inputs": { + "selected_user_id": { + "value": "{{input_parameters.user_id}}" + }, + "query": { + "value": "{{client.query}}" + } +} +``` + +The following format can be used to reference any input parameter defined by the step: `{{input_parameters.}}`. + +In addition, the `{{client.query}}` parameter can be used as a placeholder for an input value. The `{{client.builder_context}}` parameter will inject the [`slack#/types/user_context`](/tools/deno-slack-sdk/reference/slack-types/#usercontext) of the user building the workflow as the value to the input parameter. + +### Types of dynamic options UIs {#dynamic-option-UIs} + +The above example demonstrates one possible UI to be rendered for builders: a single-select drop-down menu of dynamic options. However, dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. + +The type is dictated by the output parameter of the custom step used as a dynamic option. In order to use a custom step in a dynamic option context, its output must adhere to a defined interface, that is, it must have an `options` parameter of type [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field), as shown in the following code snippet. + +```js +"output_parameters": { + "options": { + "type": "slack#/types/options_select" or "slack#/types/options_field", + "title": "Custom Options", + "description": "Options to be used in a dynamic context", + } + ... +} +``` + +#### Drop-down menus {#drop-down} + +Your dynamic input parameter can be rendered as a drop-down menu, which will use the options obtained from a custom step with an `options` output parameter of the type [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select). + +The drop-down menu UI component can be rendered in two ways: single-select, or multi-select. To render the dynamic input as a single-select menu, the input parameter defining the dynamic option must be of the type [`string`](/tools/deno-slack-sdk/reference/slack-types#string). + +```js +"step-with-dynamic-input": { + "title": "Step that uses a dynamic input", + "description": "This step uses a dynamic input rendered as a single-select menu", + "input_parameters": { + "dynamic_single_select": { + "type": "string", // this must be of type string for single-select + "title": "dynamic single select drop-down menu", + "description": "A dynamically-populated single-select drop-down menu", + "is_required": true, + "dynamic_options": { + "function": "#/functions/get-options", + "inputs": {}, + }, + } + }, + "output_parameters": {} +} +``` + +To render the dynamic input as a multi-select menu, the input parameter defining the dynamic option must be of the type [`array`](/tools/deno-slack-sdk/reference/slack-types#array), and its `items` must be of type [`string`](/tools/deno-slack-sdk/reference/slack-types#string). + +```js +"step-with-dynamic-input": { + "title": "Step that uses a dynamic input", + "description": "This step uses a dynamic input rendered as a multi-select menu", + "input_parameters": { + "dynamic_multi_select": { + "type": "array", // this must be of type array for multi-select + "items": { + "type": "string" + }, + "title": "dynamic single select drop-down menu", + "description": "A dynamically-populated multi-select drop-down menu", + "dynamic_options": { + "function": "#/functions/get-options", + "inputs": {}, + }, + } + }, + "output_parameters": {} +} +``` + +#### Fields {#fields} + +In the code snippet below, the input parameter is rendered as a set of fields with keys and values. The option fields are obtained from a custom step with an `options` output parameter of type [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field). + +The input parameter that defines the dynamic option must be of type [`object`](/tools/deno-slack-sdk/reference/slack-types#object), as the completed set of fields in Workflow Builder will be passed to the custom step as an [untyped object](/tools/deno-slack-sdk/reference/slack-types#untyped-object) during workflow execution. + +```js +"test-field-dynamic-options": { + "title": "Test dynamic field options", + "description": "", + "input_parameters": { + "dynamic_fields": { + "type": "object", + "title": "Dynamic custom field options", + "description": "A dynamically-populated section of input fields", + "dynamic_options": { + "function": "#/functions/get-field-options", + "inputs": {} + "selection_type": "key-value", + } + } + }, + "output_parameters": {} +} +``` + +### Dynamic option types {#dynamic-option-types} + +As mentioned earlier, in order to use a custom step as a dynamic option, its output must adhere to a defined interface: it must have an `options` output parameter of the type either [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field). + +To take a look at these in more detail, refer to our [Options Slack type](/tools/deno-slack-sdk/reference/slack-types#options) documentation. + +## Dynamic options handler {#dynamic-option-handler} + +Each custom step defined in the manifest needs a corresponding handler in your Slack app. Although implemented similarly to existing function execution event handlers, there are two key differences between regular custom step invocations and those used for dynamic options: + +* The custom step must have an `options` output parameter that is of type [`options_select`](/tools/deno-slack-sdk/reference/slack-types#options_select) or [`options_field`](/tools/deno-slack-sdk/reference/slack-types#options_field). +* The [`function_executed`](/reference/events/function_executed) event must be handled synchronously. This optimizes the response time of returned dynamic options and provides a crisp builder experience. + +### Asynchronous event handling {#async} + +By default, the Bolt family of frameworks handles `function_executed` events asynchronously. + +For example, the various modal-related API methods provide two ways to update a view: synchronously using a `response_action` HTTP response, or asynchronously using a separate HTTP API call. Using the asynchronous approach allows developers to handle events free of timeouts, but this isn't desired for dynamic options as it introduces delays and violates our stated goal of providing a crisp builder experience. + +### Synchronous event handling {#sync} + +Dynamic options support synchronous handling of `function_executed` events. By ensuring that the function execution’s state is complete with output parameters provided before responding to the `function_executed` event, Slack can quickly provide Workflow Builder with the requisite dynamic options. + +### Implementation {#implementation} + +To optimize the response time of dynamic options, you must acknowledge the incoming event after calling the [`function.completeSuccess`](/reference/methods/functions.completeSuccess) or [`function.completeError`](/reference/methods/functions.completeError) API methods, minimizing asynchronous latency. The `function.completeSuccess` and `function.completeError` API methods are invoked in the complete and fail helper functions. ([For example](https://github.com/slackapi/bolt-python?tab=readme-ov-file#making-things-happen)). + +A new `auto_acknowledge` flag allows you more granular control over whether specific event handlers should operate in synchronous or asynchronous response modes in order to enable a smooth dynamic options experience. + +#### Example {#bolt-py} + +In [Bolt for Python](https://docs.slack.dev/tools/bolt-python/), you can set `auto_acknowledge=False` on a specific function decorator. This allows you to manually control when the `ack()` event acknowledgement helper function is executed. It flips Bolt to synchronous `function_executed` event handling mode for the specific handler. + +```py +@app.function("get-projects", auto_acknowledge=False) +def handle_get_projects(ack: Ack, complete: Complete): + try: + complete( + outputs={ + "options": [ + { + "text": { + "type": "plain_text", + "text": "Secret Squirrel Project", + }, + "value": "p1", + }, + { + "text": { + "type": "plain_text", + "text": "Public Kangaroo Project", + }, + "value": "p2", + }, + ] + } + ) + finally: + ack() +``` + +✨ **To learn more about the Bolt family of frameworks and tools**, check out our [Slack Developer Tools](/tools). diff --git a/docs/english/concepts/custom-steps.md b/docs/english/concepts/custom-steps.md new file mode 100644 index 000000000..720c53421 --- /dev/null +++ b/docs/english/concepts/custom-steps.md @@ -0,0 +1,153 @@ +--- +sidebar_label: Custom steps +--- + +# Listening and responding to custom steps + +Your app can use the `function()` method to listen to incoming [custom step requests](/workflows/workflow-steps). Custom steps are used in Workflow Builder to build workflows. The method requires a step `callback_id` of type `str`. This `callback_id` must also be defined in your [Function](/reference/app-manifest#functions) definition. Custom steps must be finalized using the `complete()` or `fail()` listener arguments to notify Slack that your app has processed the request. + +* `complete()` requires **one** argument: `outputs` of type `dict`. It ends your custom step **successfully** and provides a dictionary containing the outputs of your custom step as per its definition. +* `fail()` requires **one** argument: `error` of type `str`. It ends your custom step **unsuccessfully** and provides a message containing information regarding why your custom step failed. + +You can reference your custom step's inputs using the `inputs` listener argument of type `dict`. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn about the available listener arguments. + +```python +# This sample custom step formats an input and outputs it +@app.function("sample_custom_step") +def sample_step_callback(inputs: dict, fail: Fail, complete: Complete): + try: + message = inputs["message"] + complete( + outputs={ + "message": f":wave: You submitted the following message: \n\n>{message}" + } + ) + except Exception as e: + fail(f"Failed to handle a custom step request (error: {e})") + raise e +``` + +
+ +Example app manifest definition + + +```json +... +"functions": { + "sample_custom_step": { + "title": "Sample custom step", + "description": "Run a sample custom step", + "input_parameters": { + "message": { + "type": "string", + "title": "Message", + "description": "A message to be formatted by the custom step", + "is_required": true, + } + }, + "output_parameters": { + "message": { + "type": "string", + "title": "Messge", + "description": "A formatted message", + "is_required": true, + } + } + } +} +``` + +
+ +--- + +### Listening to custom step interactivity events + +Your app's custom steps may create interactivity points for users, for example: Post a message with a button. + +If such interaction points originate from a custom step execution, the events sent to your app representing the end-user interaction with these points are considered to be _function-scoped interactivity events_. These interactivity events can be handled by your app using the same concepts we covered earlier, such as [Listening to actions](/tools/bolt-python/concepts/actions). + +_function-scoped interactivity events_ will contain data related to the custom step (`function_executed` event) they were spawned from, such as custom step `inputs` and access to `complete()` and `fail()` listener arguments. + +Your app can skip calling `complete()` or `fail()` in the `function()` handler method if the custom step creates an interaction point that requires user interaction before the step can end. However, in the relevant interactivity handler method, your app must invoke `complete()` or `fail()` to notify Slack that the custom step has been processed. + +You’ll notice in all interactivity handler examples, `ack()` is used. It is required to call the `ack()` function within an interactivity listener to acknowledge that the request was received from Slack. This is discussed in the [acknowledging requests section](/tools/bolt-python/concepts/acknowledge). + +```python +# This sample custom step posts a message with a button +@app.function("custom_step_button") +def sample_step_callback(inputs, say, fail): + try: + say( + channel=inputs["user_id"], # sending a DM to this user + text="Click the button to signal the step completion", + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Click the button to signal step completion"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Complete step"}, + "action_id": "sample_click", + }, + } + ], + ) + except Exception as e: + fail(f"Failed to handle a function request (error: {e})") + +# Your listener will be called every time a block element with the action_id "sample_click" is triggered +@app.action("sample_click") +def handle_sample_click(ack, body, context, client, complete, fail): + ack() + try: + # Since the button no longer works, we should remove it + client.chat_update( + channel=context.channel_id, + ts=body["message"]["ts"], + text="Congrats! You clicked the button", + ) + + # Signal that the custom step completed successfully + complete({"user_id": context.actor_user_id}) + except Exception as e: + fail(f"Failed to handle a function request (error: {e})") +``` + +
+ +Example app manifest definition + + +```json +... +"functions": { + "custom_step_button": { + "title": "Custom step with a button", + "description": "Custom step that waits for a button click", + "input_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "The recipient of a message with a button", + "is_required": true, + } + }, + "output_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "The user that completed the function", + "is_required": true + } + } + } +} +``` + +
+ +Learn more about responding to interactivity, see the [Slack API documentation](/interactivity/handling-user-interaction). diff --git a/docs/english/concepts/errors.md b/docs/english/concepts/errors.md new file mode 100644 index 000000000..7b40adb7f --- /dev/null +++ b/docs/english/concepts/errors.md @@ -0,0 +1,14 @@ +# Handling errors + +If an error occurs in a listener, you can handle it directly using a try/except block. Errors associated with your app will be of type `BoltError`. Errors associated with calling Slack APIs will be of type `SlackApiError`. + +By default, the global error handler will log all non-handled exceptions to the console. To handle global errors yourself, you can attach a global error handler to your app using the `app.error(fn)` function. + +## Example + +```python +@app.error +def custom_error_handler(error, body, logger): + logger.exception(f"Error: {error}") + logger.info(f"Request body: {body}") +``` \ No newline at end of file diff --git a/docs/english/concepts/event-listening.md b/docs/english/concepts/event-listening.md new file mode 100644 index 000000000..d7b8e5930 --- /dev/null +++ b/docs/english/concepts/event-listening.md @@ -0,0 +1,34 @@ +# Listening to events + +You can listen to [any Events API event](/reference/events) using the `event()` method after subscribing to it in your app configuration. This allows your app to take action when something happens in a workspace where it's installed, like a user reacting to a message or joining a channel. + +The `event()` method requires an `eventType` of type `str`. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. +```python +# When a user joins the workspace, send a message in a predefined channel asking them to introduce themselves +@app.event("team_join") +def ask_for_introduction(event, say): + welcome_channel_id = "C12345" + user_id = event["user"] + text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." + say(text=text, channel=welcome_channel_id) +``` + +## Filtering on message subtypes + +The `message()` listener is equivalent to `event("message")`. + +You can filter on subtypes of events by passing in the additional key `subtype`. Common message subtypes like `bot_message` and `message_replied` can be found [on the message event page](/reference/events/message#subtypes). +You can explicitly filter for events without a subtype by explicitly setting `None`. + +```python +# Matches all modified messages +@app.event({ + "type": "message", + "subtype": "message_changed" +}) +def log_message_change(logger, event): + user, text = event["user"], event["text"] + logger.info(f"The user {user} changed the message to {text}") +``` \ No newline at end of file diff --git a/docs/english/concepts/global-middleware.md b/docs/english/concepts/global-middleware.md new file mode 100644 index 000000000..7b7bdb059 --- /dev/null +++ b/docs/english/concepts/global-middleware.md @@ -0,0 +1,32 @@ +# Global middleware + +Global middleware is run for all incoming requests, before any listener middleware. You can add any number of global middleware to your app by passing middleware functions to `app.use()`. Middleware functions are called with the same arguments as listeners, with an additional `next()` function. + +Both global and listener middleware must call `next()` to pass control of the execution chain to the next middleware. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + +```python +@app.use +def auth_acme(client, context, logger, payload, next): + slack_user_id = payload["user"] + help_channel_id = "C12345" + + try: + # Look up user in external system using their Slack user ID + user = acme.lookup_by_id(slack_user_id) + # Add that to context + context["user"] = user + except Exception: + client.chat_postEphemeral( + channel=payload["channel"], + user=slack_user_id, + text=f"Sorry <@{slack_user_id}>, you aren't registered in Acme or there was an error with authentication. Please post in <#{help_channel_id}> for assistance" + ) + + # Pass control to the next middleware + next() +``` + diff --git a/docs/english/concepts/lazy-listeners.md b/docs/english/concepts/lazy-listeners.md new file mode 100644 index 000000000..d775106b9 --- /dev/null +++ b/docs/english/concepts/lazy-listeners.md @@ -0,0 +1,100 @@ +# Lazy listeners (FaaS) + +Lazy Listeners are a feature which make it easier to deploy Slack apps to FaaS (Function-as-a-Service) environments. Please note that this feature is only available in Bolt for Python, and we are not planning to add the same to other Bolt frameworks. + +Typically when handling actions, commands, shortcuts, options and view submissions, you must acknowledge the request from Slack by calling `ack()` within 3 seconds. Calling `ack()` results in sending an HTTP 200 OK response to Slack, letting Slack know that you're handling the response. We normally encourage you to do this as the very first step in your handler function. + +However, when running your app on FaaS or similar runtimes which **do not allow you to run threads or processes after returning an HTTP response**, we cannot follow the typical pattern of acknowledgement first, processing later. To work with these runtimes, set the `process_before_response` flag to `True`. When this flag is true, the Bolt framework holds off sending an HTTP response until all the things in a listener function are done. You need to complete your processing within 3 seconds or you will run into errors with Slack timeouts. Note that in the case of events, while the listener doesn't need to explicitly call the `ack()` method, it still needs to complete its function within 3 seconds as well. + +To allow you to still run more time-consuming processes as part of your handler, we've added a lazy listener function mechanism. Rather than acting as a decorator, a lazy listener accepts two keyword args: +* `ack: Callable`: Responsible for calling `ack()` within 3 seconds +* `lazy: List[Callable]`: Responsible for handling time-consuming processes related to the request. The lazy function does not have access to `ack()`. + +```python +def respond_to_slack_within_3_seconds(body, ack): + text = body.get("text") + if text is None or len(text) == 0: + ack(f":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # longer than 3 seconds + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + # ack() is still called within 3 seconds + ack=respond_to_slack_within_3_seconds, + # Lazy function is responsible for processing the event + lazy=[run_long_process] +) +``` + +## Example with AWS Lambda + +This example deploys the code to [AWS Lambda](https://aws.amazon.com/lambda/). There are more examples within the [`examples`](https://github.com/slackapi/bolt-python/tree/main/examples/aws_lambda) folder. + +```bash +pip install slack_bolt +# Save the source code as main.py +# and refer handler as `handler: main.handler` in config.yaml + +# https://pypi.org/project/python-lambda/ +pip install python-lambda + +# Configure config.yml properly +# lambda:InvokeFunction & lambda:GetFunction are required for running lazy listeners +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +echo 'slack_bolt' > requirements.txt +lambda deploy --config-file config.yaml --requirements requirements.txt +``` + +```python +from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler + +# process_before_response must be True when running on FaaS +app = App(process_before_response=True) + +def respond_to_slack_within_3_seconds(body, ack): + text = body.get("text") + if text is None or len(text) == 0: + ack(":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # longer than 3 seconds + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + ack=respond_to_slack_within_3_seconds, # responsible for calling `ack()` + lazy=[run_long_process] # unable to call `ack()` / can have multiple functions +) + +def handler(event, context): + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context) +``` + +Please note that the following IAM permissions would be required for running this example app. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "lambda:GetFunction" + ], + "Resource": "*" + } + ] +} +``` \ No newline at end of file diff --git a/docs/english/concepts/listener-middleware.md b/docs/english/concepts/listener-middleware.md new file mode 100644 index 000000000..dd020373f --- /dev/null +++ b/docs/english/concepts/listener-middleware.md @@ -0,0 +1,33 @@ +# Listener middleware + +Listener middleware is only run for the listener in which it's passed. You can pass any number of middleware functions to the listener using the `middleware` parameter, which must be a list that contains one to many middleware functions. + +If your listener middleware is a quite simple one, you can use a listener matcher, which returns `bool` value (`True` for proceeding) instead of requiring `next()` method call. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + +```python +# Listener middleware which filters out messages from a bot +def no_bot_messages(message, next): + if "bot_id" not in message: + next() + +# This listener only receives messages from humans +@app.event(event="message", middleware=[no_bot_messages]) +def log_message(logger, event): + logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") + +# Listener matchers: simplified version of listener middleware +def no_bot_messages(message) -> bool: + return "bot_id" not in message + +@app.event( + event="message", + matchers=[no_bot_messages] + # or matchers=[lambda message: message.get("subtype") != "bot_message"] +) +def log_message(logger, event): + logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") +``` diff --git a/docs/english/concepts/logging.md b/docs/english/concepts/logging.md new file mode 100644 index 000000000..599431550 --- /dev/null +++ b/docs/english/concepts/logging.md @@ -0,0 +1,23 @@ +# Logging + +By default, Bolt will log information from your app to the output destination. After you've imported the `logging` module, you can customize the root log level by passing the `level` parameter to `basicConfig()`. The available log levels in order of least to most severe are `debug`, `info`, `warning`, `error`, and `critical`. + +Outside of a global context, you can also log a single message corresponding to a specific level. Because Bolt uses Python’s [standard logging module](https://docs.python.org/3/library/logging.html), you can use any its features. + +## Example + +```python +import logging + +# logger in a global context +# requires importing logging +logging.basicConfig(level=logging.DEBUG) + +@app.event("app_mention") +def handle_mention(body, say, logger): + user = body["event"]["user"] + # single logger call + # global logger is passed to listener + logger.debug(body) + say(f"{user} mentioned your app") +``` diff --git a/docs/english/concepts/message-listening.md b/docs/english/concepts/message-listening.md new file mode 100644 index 000000000..be6e74678 --- /dev/null +++ b/docs/english/concepts/message-listening.md @@ -0,0 +1,30 @@ +# Listening to messages +To listen to messages that [your app has access to receive](/messaging/retrieving-messages), you can use the `message()` method which filters out events that aren't of type `message`. + +`message()` accepts an argument of type `str` or `re.Pattern` object that filters out any messages that don't match the pattern. + +:::info[Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] + +::: + +```python +# This will match any message that contains 👋 +@app.message(":wave:") +def say_hello(message, say): + user = message['user'] + say(f"Hi there, <@{user}>!") +``` + +## Using a regular expression pattern + +The `re.compile()` method can be used instead of a string for more granular matching. + +```python +import re + +@app.message(re.compile("(hi|hello|hey)")) +def say_hello_regex(say, context): + # regular expression matches are inside of context.matches + greeting = context['matches'][0] + say(f"{greeting}, how are you?") +``` \ No newline at end of file diff --git a/docs/english/concepts/message-sending.md b/docs/english/concepts/message-sending.md new file mode 100644 index 000000000..c4d1b0467 --- /dev/null +++ b/docs/english/concepts/message-sending.md @@ -0,0 +1,119 @@ +# Sending messages + +Within your listener function, `say()` is available whenever there is an associated conversation (for example, a conversation where the event or action which triggered the listener occurred). `say()` accepts a string to post simple messages and JSON payloads to send more complex messages. The message payload you pass in will be sent to the associated conversation. + +In the case that you'd like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](/tools/bolt-python/concepts/web-api). + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +```python +# Listens for messages containing "knock knock" and responds with an italicized "who's there?" +@app.message("knock knock") +def ask_who(message, say): + say("_Who's there?_") +``` + +## Sending a message with blocks + +`say()` accepts more complex message payloads to make it easy to add functionality and structure to your messages. + +To explore adding rich message layouts to your app, read through [the guide on our API site](/messaging/#structure) and look through templates of common app flows [in the Block Kit Builder](https://api.slack.com/tools/block-kit-builder?template=1). + +```python +# Sends a section block with datepicker when someone reacts with a 📅 emoji +@app.event("reaction_added") +def show_datepicker(event, say): + reaction = event["reaction"] + if reaction == "calendar": + blocks = [{ + "type": "section", + "text": {"type": "mrkdwn", "text": "Pick a date for me to remind you"}, + "accessory": { + "type": "datepicker", + "action_id": "datepicker_remind", + "initial_date": "2020-05-04", + "placeholder": {"type": "plain_text", "text": "Select a date"} + } + }] + say( + blocks=blocks, + text="Pick a date for me to remind you" + ) +``` + +## Streaming messages {#streaming-messages} + +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`. + +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() +``` + +#### Adding feedback buttons after a stream + +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( + 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 +``` \ No newline at end of file diff --git a/docs/english/concepts/opening-modals.md b/docs/english/concepts/opening-modals.md new file mode 100644 index 000000000..01716f613 --- /dev/null +++ b/docs/english/concepts/opening-modals.md @@ -0,0 +1,53 @@ +# Opening modals + +[Modals](/surfaces/modals) are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid `trigger_id` and a [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission) to the built-in client's [`views.open`](/reference/methods/views.open/) method. + +Your app receives `trigger_id` parameters in payloads sent to your Request URL triggered user invocation like a slash command, button press, or interaction with a select menu. + +Read more about modal composition in the [API documentation](/surfaces/modals#composing_views). + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + +```python +# Listen for a shortcut invocation +@app.shortcut("open_modal") +def open_modal(ack, body, client): + # Acknowledge the command request + ack() + # Call views_open with the built-in client + client.views_open( + # Pass a valid trigger_id within 3 seconds of receiving it + trigger_id=body["trigger_id"], + # View payload + view={ + "type": "modal", + # View identifier + "callback_id": "view_1", + "title": {"type": "plain_text", "text": "My App"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "blocks": [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Welcome to a modal with _blocks_"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click me!"}, + "action_id": "button_abc" + } + }, + { + "type": "input", + "block_id": "input_c", + "label": {"type": "plain_text", "text": "What are your hopes and dreams?"}, + "element": { + "type": "plain_text_input", + "action_id": "dreamy_input", + "multiline": True + } + } + ] + } + ) +``` diff --git a/docs/english/concepts/select-menu-options.md b/docs/english/concepts/select-menu-options.md new file mode 100644 index 000000000..8e6cbb9fe --- /dev/null +++ b/docs/english/concepts/select-menu-options.md @@ -0,0 +1,34 @@ +# Listening & responding to select menu options + +The `options()` method listens for incoming option request payloads from Slack. [Similar to `action()`](/tools/bolt-python/concepts/actions), +an `action_id` or constraints object is required. In order to load external data into your select menus, you must provide an options load URL in your app configuration, appended with `/slack/events`. + +While it's recommended to use `action_id` for `external_select` menus, dialogs do not support Block Kit so you'll have to use the constraints object to filter on a `callback_id`. + +To respond to options requests, you'll need to call `ack()` with a valid `options` or `option_groups` list. Both [external select response examples](/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select) and [dialog response examples](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) can be found on our API site. + +Additionally, you may want to apply filtering logic to the returned options based on user input. This can be accomplished by using the `payload` argument to your options listener and checking for the contents of the `value` property within it. Based on the `value` you can return different options. All listeners and middleware handlers in Bolt for Python have access to [many useful arguments](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) - be sure to check them out! + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. + +## Example + +```python +# Example of responding to an external_select options request +@app.options("external_action") +def show_options(ack, payload): + options = [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ] + keyword = payload.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in options if keyword in o["text"]["text"]] + ack(options=options) +``` \ No newline at end of file diff --git a/docs/english/concepts/shortcuts.md b/docs/english/concepts/shortcuts.md new file mode 100644 index 000000000..b28f0b352 --- /dev/null +++ b/docs/english/concepts/shortcuts.md @@ -0,0 +1,89 @@ +# Listening & responding to shortcuts + +The `shortcut()` method supports both [global shortcuts](/interactivity/implementing-shortcuts#global) and [message shortcuts](/interactivity/implementing-shortcuts#messages). + +Shortcuts are invokable entry points to apps. Global shortcuts are available from within search and text composer area in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut requests. The method requires a `callback_id` parameter of type `str` or `re.Pattern`. + +Shortcuts must be acknowledged with `ack()` to inform Slack that your app has received the request. + +Shortcuts include a `trigger_id` which an app can use to [open a modal](/tools/bolt-python/concepts/opening-modals) that confirms the action the user is taking. + +When setting up shortcuts within your app configuration, as with other URLs, you'll append `/slack/events` to your request URL. + +⚠️ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) element within a modal. Message shortcuts do include a channel ID. + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. +```python +# The open_modal shortcut listens to a shortcut with the callback_id "open_modal" +@app.shortcut("open_modal") +def open_modal(ack, shortcut, client): + # Acknowledge the shortcut request + ack() + # Call the views_open method using the built-in WebClient + client.views_open( + trigger_id=shortcut["trigger_id"], + # A simple view payload for a modal + view={ + "type": "modal", + "title": {"type": "plain_text", "text": "My App"}, + "close": {"type": "plain_text", "text": "Close"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` + +## Listening to shortcuts using a constraint object + +You can use a constraints object to listen to `callback_id`s, and `type`s. Constraints in the object can be of type `str` or `re.Pattern`. + +```python +# Your listener will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' +@app.shortcut({"callback_id": "open_modal", "type": "message_action"}) +def open_modal(ack, shortcut, client): + # Acknowledge the shortcut request + ack() + # Call the views_open method using one of the built-in WebClients + client.views_open( + trigger_id=shortcut["trigger_id"], + view={ + "type": "modal", + "title": {"type": "plain_text", "text": "My App"}, + "close": {"type": "plain_text", "text": "Close"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` \ No newline at end of file diff --git a/docs/english/concepts/socket-mode.md b/docs/english/concepts/socket-mode.md new file mode 100644 index 000000000..5156f6e13 --- /dev/null +++ b/docs/english/concepts/socket-mode.md @@ -0,0 +1,55 @@ +# Using Socket Mode + +With the introduction of [Socket Mode](/apis/events-api/using-socket-mode), Bolt for Python introduced support in version `1.2.0`. With Socket Mode, instead of creating a server with endpoints that Slack sends payloads too, the app will instead connect to Slack via a WebSocket connection and receive data from Slack over the socket connection. Make sure to enable Socket Mode in your app configuration settings. + +To use the Socket Mode, add `SLACK_APP_TOKEN` as an environment variable. You can get your App Token in your app configuration settings under the **Basic Information** section. + +While we recommend using [the built-in Socket Mode adapter](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin), there are a few other 3rd party library based implementations. Here is the list of available adapters. + +|PyPI Project|Bolt Adapter| +|-|-| +|[slack_sdk](https://pypi.org/project/slack-sdk/)|[slack_bolt.adapter.socket_mode](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin)| +|[websocket_client](https://pypi.org/project/websocket_client/)|[slack_bolt.adapter.socket_mode.websocket_client](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/websocket_client)| +|[aiohttp](https://pypi.org/project/aiohttp/) (asyncio-based)|[slack_bolt.adapter.socket_mode.aiohttp](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/aiohttp)| +|[websockets](https://pypi.org/project/websockets/) (asyncio-based)|[slack_bolt.adapter.socket_mode.websockets](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/websockets)| + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Install the Slack app and get xoxb- token in advance +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + +# Add middleware / listeners here + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + handler.start() +``` + +## Using Async (asyncio) + +To use the asyncio-based adapters such as aiohttp, your whole app needs to be compatible with asyncio's async/await programming model. `AsyncSocketModeHandler` is available for running `AsyncApp` and its async middleware and listeners. + +To learn how to use `AsyncApp`, checkout the [using Async](/tools/bolt-python/concepts/async) document and relevant [examples](https://github.com/slackapi/bolt-python/tree/main/examples). + +```python +from slack_bolt.app.async_app import AsyncApp +# The default is the aiohttp based implementation +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + +# Add middleware / listeners here + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) +``` \ No newline at end of file diff --git a/docs/english/concepts/token-rotation.md b/docs/english/concepts/token-rotation.md new file mode 100644 index 000000000..96a41bb3c --- /dev/null +++ b/docs/english/concepts/token-rotation.md @@ -0,0 +1,9 @@ +# Token rotation + +Supported in Bolt for Python as of [v1.7.0](https://github.com/slackapi/bolt-python/releases/tag/v1.7.0), token rotation provides an extra layer of security for your access tokens and is defined by the [OAuth V2 RFC](https://datatracker.ietf.org/doc/html/rfc6749#section-10.4). + +Instead of an access token representing an existing installation of your Slack app indefinitely, with token rotation enabled, access tokens expire. A refresh token acts as a long-lived way to refresh your access tokens. + +Bolt for Python supports and will handle token rotation automatically so long as the [built-in OAuth](/tools/bolt-python/concepts/authenticating-oauth) functionality is used. + +For more information about token rotation, please see the [documentation](/authentication/using-token-rotation). \ No newline at end of file diff --git a/docs/english/concepts/updating-pushing-views.md b/docs/english/concepts/updating-pushing-views.md new file mode 100644 index 000000000..ce285c814 --- /dev/null +++ b/docs/english/concepts/updating-pushing-views.md @@ -0,0 +1,47 @@ +# Updating & pushing views + +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 + +To update a view, you can use the built-in client to call `views_update` with the `view_id` that was generated when you opened the view, and a new `view` including the updated `blocks` list. If you're updating the view when a user interacts with an element inside of an existing view, the `view_id` will be available in the `body` of the request. + +## The `views_push` method + +To push a new view onto the view stack, you can use the built-in client to call `views_push` with a valid `trigger_id` a new [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission). The arguments for `views_push` is the same as [opening modals](/tools/bolt-python/concepts/opening-modals). After you open a modal, you may only push two additional views onto the view stack. + +Learn more about updating and pushing views in our [API documentation](/surfaces/modals) + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. +```python +# Listen for a button invocation with action_id `button_abc` (assume it's inside of a modal) +@app.action("button_abc") +def update_modal(ack, body, client): + # Acknowledge the button request + ack() + # Call views_update with the built-in client + client.views_update( + # Pass the view_id + view_id=body["view"]["id"], + # String that represents view state to protect against race conditions + hash=body["view"]["hash"], + # View payload with updated blocks + view={ + "type": "modal", + # View identifier + "callback_id": "view_1", + "title": {"type": "plain_text", "text": "Updated modal"}, + "blocks": [ + { + "type": "section", + "text": {"type": "plain_text", "text": "You updated the modal!"} + }, + { + "type": "image", + "image_url": "https://media.giphy.com/media/SVZGEcYt7brkFUyU90/giphy.gif", + "alt_text": "Yay! The modal was updated" + } + ] + } + ) +``` \ No newline at end of file diff --git a/docs/english/concepts/using-the-assistant-class.md b/docs/english/concepts/using-the-assistant-class.md new file mode 100644 index 000000000..55ca3d110 --- /dev/null +++ b/docs/english/concepts/using-the-assistant-class.md @@ -0,0 +1,333 @@ +# 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` 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: + +1. [The user starts a thread](#handling-new-thread). The `Assistant` class handles the incoming [`assistant_thread_started`](/reference/events/assistant_thread_started) event. +2. [The thread context may change at any point](#handling-thread-context-changes). The `Assistant` class can handle any incoming [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) events. The class also provides a default `context` store to keep track of thread context changes as the user moves through Slack. +3. [The user responds](#handling-user-response). The `Assistant` class handles the incoming [`message.im`](/reference/events/message.im) event. + + +```python +assistant = Assistant() + +# This listener is invoked when a human user opened an assistant thread +@assistant.thread_started +def start_assistant_thread( + say: Say, + get_thread_context: GetThreadContext, + set_suggested_prompts: SetSuggestedPrompts, + logger: logging.Logger, +): + try: + ... + +# This listener is invoked when the human user sends a reply in the assistant thread +@assistant.user_message +def respond_in_assistant_thread( + client: WebClient, + context: BoltContext, + get_thread_context: GetThreadContext, + logger: logging.Logger, + payload: dict, + say: Say, + set_status: SetStatus, +): + try: + ... + +# Enable this assistant middleware in your Bolt app +app.use(assistant) +``` + +:::info[Consider the following] +You _could_ go it alone and [listen](/tools/bolt-python/concepts/event-listening) for the `assistant_thread_started`, `assistant_thread_context_changed`, and `message.im` events in order to implement the AI features in your app. That being said, using the `Assistant` class will streamline the process. And we already wrote this nice guide for you! +::: + +While the `assistant_thread_started` and `assistant_thread_context_changed` events do provide Slack-client thread context information, the `message.im` event does not. Any subsequent user message events won't contain thread context data. For that reason, Bolt not only provides a way to store thread context — the `threadContextStore` property — but it also provides a `DefaultThreadContextStore` instance that is utilized by default. This implementation relies on storing and retrieving [message metadata](/messaging/message-metadata/) as the user interacts with the app. + +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} + +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) + * [`chat:write`](/reference/scopes/chat.write) + * [`im:history`](/reference/scopes/im.history) + +3. Within the App Settings **Event Subscriptions** page, subscribe to the following events: + * [`assistant_thread_started`](/reference/events/assistant_thread_started) + * [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) + * [`message.im`](/reference/events/message.im) + +## Handling a new thread {#handling-new-thread} + +When the user opens a new thread with your AI-enabled app, the [`assistant_thread_started`](/reference/events/assistant_thread_started) event will be sent to your app. + +:::tip[When a user opens an app thread while in a channel, the channel info is stored as the thread's `AssistantThreadContext` data.] + +You can grab that info by using the `get_thread_context` utility, as subsequent user message event payloads won't include the channel info. +::: + +```python +assistant = Assistant() + +@assistant.thread_started +def start_assistant_thread( + say: Say, + get_thread_context: GetThreadContext, + set_suggested_prompts: SetSuggestedPrompts, + logger: logging.Logger, +): + try: + say("How can I help you?") + + prompts: List[Dict[str, str]] = [ + { + "title": "Suggest names for my Slack app", + "message": "Can you suggest a few names for my Slack app? The app helps my teammates better organize information and plan priorities and action items.", + }, + ] + + thread_context = get_thread_context() + if thread_context is not None and thread_context.channel_id is not None: + summarize_channel = { + "title": "Summarize the referred channel", + "message": "Can you generate a brief summary of the referred channel?", + } + prompts.append(summarize_channel) + + set_suggested_prompts(prompts=prompts) + except Exception as e: + logger.exception(f"Failed to handle an assistant_thread_started event: {e}", e) + say(f":warning: Something went wrong! ({e})") +``` + +You can send more complex messages to the user — see [Sending Block Kit alongside messages](#block-kit-interactions) for more info. + +## Handling thread context changes {#handling-thread-context-changes} + +When the user switches channels, the [`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed) event will be sent to your app. + +If you use the built-in `Assistant` middleware without any custom configuration, the updated context data is automatically saved as [message metadata](/messaging/message-metadata/) of the first reply from the app. + +As long as you use the built-in approach, you don't need to store the context data within a datastore. The downside of this default behavior is the overhead of additional calls to the Slack API. These calls include those to `conversations.history`, which are used to look up the stored message metadata that contains the thread context (via `get_thread_context`). + +To store context elsewhere, pass a custom `AssistantThreadContextStore` implementation to the `Assistant` constructor. We provide `FileAssistantThreadContextStore`, which is a reference implementation that uses the local file system. Since this reference implementation relies on local files, it's not advised for use in production. For production apps, we recommend creating a class that inherits `AssistantThreadContextStore`. + +```python +from slack_bolt import FileAssistantThreadContextStore +assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) +``` + +## Handling the user response {#handling-user-response} + +When the user messages your app, the [`message.im`](/reference/events/message.im) event will be sent to your app. + +Messages sent to the app do not contain a [subtype](/reference/events/message#subtypes) and must be deduced based on their shape and any provided [message metadata](/messaging/message-metadata/). + +There are three utilities that are particularly useful in curating the user experience: +* [`say`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.Say) +* [`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 `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 +@assistant.user_message +def respond_in_assistant_thread( + client: WebClient, + context: BoltContext, + get_thread_context: GetThreadContext, + logger: logging.Logger, + payload: dict, + say: Say, + set_status: SetStatus, +): + try: + channel_id = payload["channel"] + team_id = payload["team"] + thread_ts = payload["thread_ts"] + user_id = payload["user"] + user_message = payload["text"] + + set_status( + status="thinking...", + loading_messages=[ + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Convincing the AI to stop overthinking…", + ], + ) + + # Collect the conversation history with this user + replies = 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) + + # Post the result in the assistant thread + say(text=returned_message) + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + # Don't forget sending a message telling the error + # Without this, the status 'is typing...' won't be cleared, therefore the end-user is unable to continue the chat + say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + +# Enable this assistant middleware in your Bolt app +app.use(assistant) +``` + +## 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. + +For example, an app can display a button such as "Summarize the referring channel" in the initial reply. When the user clicks the button and submits detailed information (such as the number of messages, days to check, purpose of the summary, etc.), the app can handle that information and post a message that describes the request with structured metadata. + +By default, apps can't respond to their own bot messages (Bolt prevents infinite loops by default). However, if you pass `ignoring_self_assistant_message_events_enabled=False` to the `App` constructor and add a `bot_message` listener to your `Assistant` middleware, your app can continue processing the request as shown below: + +```python +app = App( + token=os.environ["SLACK_BOT_TOKEN"], + # This must be set to handle bot message events + ignoring_self_assistant_message_events_enabled=False, +) + +assistant = Assistant() + +@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": [ + # You can have multiple buttons here + { + "type": "button", + "action_id": "assistant-generate-random-numbers", + "text": {"type": "plain_text", "text": "Generate random numbers"}, + "value": "clicked", + }, + ], + }, + ], + ) + +# This listener is invoked when the above button is 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"}, + # Relay the assistant thread information to app.view listener + "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"}, + # You can have this kind of predefined input from a user instead of parsing human text + "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"}, + }, + } + ], + }, + ) + +# This listener is invoked when the above modal is submitted +@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"]) + + # Post a bot message with structured input data + # The following assistant.bot_message will continue processing + # If you prefer processing this request within this listener, it also works! + # If you don't need bot_message listener, no need to set 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)}, + }, + ) + +# This listener is invoked whenever your app's bot user posts a message +@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": + # Handle the above random-number-generation request + 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: + # nothing to do for this bot message + # If you want to add more patterns here, be careful not to cause infinite loop messaging + pass + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") +... +``` + +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 [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 new file mode 100644 index 000000000..b961e0376 --- /dev/null +++ b/docs/english/concepts/view-submissions.md @@ -0,0 +1,95 @@ +# Listening to views + +If a [view payload](/reference/interaction-payloads/view-interactions-payload/#view_submission) contains any input blocks, you must listen to `view_submission` requests to receive their values. To listen to `view_submission` requests, you can use the built-in `view()` method. `view()` requires a `callback_id` of type `str` or `re.Pattern`. + +You can access the value of the `input` blocks by accessing the `state` object. `state` contains a `values` object that uses the `block_id` and unique `action_id` to store the input values. + +--- + +##### Update views on submission + +To update a view in response to a `view_submission` event, you may pass a `response_action` of type `update` with a newly composed `view` to display in your acknowledgement. + +```python +# Update the view on submission +@app.view("view_1") +def handle_submission(ack, body): + # The build_new_view() method returns a modal view + # To build a modal view, we recommend using Block Kit Builder: + # https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22view_1%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22My%20App%22,%22emoji%22:true%7D,%22blocks%22:%5B%5D%7D + ack(response_action="update", view=build_new_view(body)) +``` +Similarly, there are options for [displaying errors](/surfaces/modals#displaying_errors) in response to view submissions. + +Read more about view submissions in our [API documentation](/surfaces/modals#interactions) + +--- + +##### Handling views on close + +When listening for `view_closed` requests, you must pass `callback_id` and add a `notify_on_close` property to the view during creation. See below for an example of this: + +See the [API documentation](/surfaces/modals#interactions) for more information about `view_closed`. + +```python + +client.views_open( + trigger_id=body.get("trigger_id"), + view={ + "type": "modal", + "callback_id": "modal-id", # Used when calling view_closed + "title": { + "type": "plain_text", + "text": "Modal title" + }, + "blocks": [], + "close": { + "type": "plain_text", + "text": "Cancel" + }, + "notify_on_close": True, # This attribute is required + } +) + +# Handle a view_closed request +@app.view_closed("modal-id") +def handle_view_closed(ack, body, logger): + ack() + logger.info(body) +``` + +Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments. +```python +# Handle a view_submission request +@app.view("view_1") +def handle_submission(ack, body, client, view, logger): + # Assume there's an input block with `input_c` as the block_id and `dreamy_input` + hopes_and_dreams = view["state"]["values"]["input_c"]["dreamy_input"] + user = body["user"]["id"] + # Validate the inputs + errors = {} + if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5: + errors["input_c"] = "The value must be longer than 5 characters" + if len(errors) > 0: + ack(response_action="errors", errors=errors) + return + # Acknowledge the view_submission request and close the modal + ack() + # Do whatever you want with the input data - here we're saving it to a DB + # then sending the user a verification of their submission + + # Message to send user + msg = "" + try: + # Save to DB + msg = f"Your submission of {hopes_and_dreams} was successful" + except Exception as e: + # Handle error + msg = "There was an error with your submission" + + # Message the user + try: + client.chat_postMessage(channel=user, text=msg) + except Exception as e: + logger.exception(f"Failed to post a message {e}") +``` diff --git a/docs/english/concepts/web-api.md b/docs/english/concepts/web-api.md new file mode 100644 index 000000000..81f7c9b60 --- /dev/null +++ b/docs/english/concepts/web-api.md @@ -0,0 +1,24 @@ +# Using the Web API + +You can call [any Web API method](/reference/methods) using the `WebClient` provided to your Bolt app as either `app.client` or `client` in middleware/listener arguments (given that your app has the appropriate scopes). When you call one the client's methods, it returns a `SlackResponse` which contains the response from Slack. + +The token used to initialize Bolt can be found in the `context` object, which is required to call most Web API methods. + +:::info[Refer to [the module document](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] + +::: + +## Example + +```python +@app.message("wake me up") +def say_hello(client, message): + # Unix Epoch time for September 30, 2020 11:59:59 PM + when_september_ends = 1601510399 + channel_id = message["channel"] + client.chat_scheduleMessage( + channel=channel_id, + post_at=when_september_ends, + text="Summer has come and passed" + ) +``` diff --git a/docs/english/creating-an-app.md b/docs/english/creating-an-app.md new file mode 100644 index 000000000..66e1febee --- /dev/null +++ b/docs/english/creating-an-app.md @@ -0,0 +1,482 @@ +--- +sidebar_label: Creating an app +--- + +# 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. + +When you're finished, you'll have created the [Getting Started app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started) to run, modify, and make your own. ⚡️ + +--- + +### Create 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] + +We recommend using a workspace where you won't disrupt real work getting done — [you can create a new one for free](https://slack.com/get-started#create). + +::: + +After you fill out an app name (_you can change it later_) and pick a workspace to install it to, hit the `Create App` button and you'll land on your app's **Basic Information** page. + +This page contains an overview of your app in addition to important credentials you'll need later. + +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information page") + +Look around, add an app icon and description, and then let's start configuring your app 🔩 + +--- + +### Tokens and installing apps {#tokens-and-installing-apps} +Slack apps use [OAuth to manage access to Slack's APIs](/authentication/installing-with-oauth). When an app is installed, you'll receive a token that the app can use to call API methods. + +There are three main token types available to a Slack app: user (`xoxp`), bot (`xoxb`), and app-level (`xapp`) tokens. +- [User tokens](/authentication/tokens#user) allow you to call API methods on behalf of users after they install or authenticate the app. There may be several user tokens for a single workspace. +- [Bot tokens](/authentication/tokens#bot) are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that _most_ apps use. +- [App-level tokens](/authentication/tokens#app-level) represent your app across organizations, including installations by all individual users on all workspaces in a given organization and are commonly used for creating WebSocket connections to your app. + +We're going to use bot and app-level tokens for this guide. + +1. Navigate to **OAuth & Permissions** on the left sidebar and scroll down to the **Bot Token Scopes** section. Click **Add an OAuth Scope**. + +2. For now, we'll just add one scope: [`chat:write`](/reference/scopes/chat.write). This grants your app the permission to post messages in channels it's a member of. + +3. Scroll up to the top of the **OAuth & Permissions** page and click **Install App to Workspace**. You'll be led through Slack's OAuth UI, where you should allow your app to be installed to your development workspace. + +4. Once you authorize the installation, you'll land on the **OAuth & Permissions** page and see a **Bot User OAuth Access Token**. + +![OAuth Tokens](/img/bolt-python/bot-token.png "Bot OAuth Token") + +5. Head over to **Basic Information** and scroll down under the App Token section and click **Generate Token and Scopes** to generate an app-level token. Add the `connections:write` scope to this token and save the generated `xapp` token. + +6. Navigate to **Socket Mode** on the left side menu and toggle to enable. + +:::tip[Not sharing is sometimes caring] + +Treat your tokens like passwords and [keep them safe](/concepts/security). Your app uses tokens to post and retrieve information from Slack workspaces. + +::: + +--- + +### Setting up your project {#setting-up-your-project} + +With the initial configuration handled, it's time to set up a new Bolt project. This is where you'll write the code that handles the logic for your app. + +If you don’t already have a project, let’s create a new one. Create an empty directory: + +```sh +$ mkdir first-bolt-app +$ cd first-bolt-app +``` + +Next, we recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.7 or later](https://www.python.org/downloads/): + +```sh +$ python3 -m venv .venv +$ source .venv/bin/activate +$ pip install -r requirements.txt +``` + +We can confirm that the virtual environment is active by checking that the path to `python3` is _inside_ your project ([a similar command is available on Windows](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment)): + +```sh +$ which python3 +# Output: /path/to/first-bolt-app/.venv/bin/python3 +``` + +Before we install the Bolt for Python package to your new project, let's save the **bot token** and **app-level token** that were generated when you configured your app. + +1. **Copy your bot (xoxb) token from the OAuth & Permissions page** and store it in a new environment variable. The following example works on Linux and macOS; but [similar commands are available on Windows](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153). + +```sh +$ export SLACK_BOT_TOKEN=xoxb- +``` + +2. **Copy your app-level (xapp) token from the Basic Information page** and then store it in a new environment variable. + +```sh +$ export SLACK_APP_TOKEN= +``` + +:::warning[Keep it secret. Keep it safe.] + +Remember to keep your tokens secure. At a minimum, you should avoid checking them into public version control, and access them via environment variables as we've done above. Check out the API documentation for more on [best practices for app security](/concepts/security). + +::: + +Now, let's create your app. Install the `slack_bolt` Python package to your virtual environment using the following command: + +```sh +$ pip install slack_bolt +``` + +Create a new file called `app.py` in this directory and add the following code: + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + +Your tokens are enough to create your first Bolt app. Save your `app.py` file then on the command line run the following: + +```sh +$ python3 app.py +``` + +Your app should let you know that it's up and running. 🎉 + +--- + +### Setting up events {#setting-up-events} +Your app behaves similarly to people on your team — it can post messages, add emoji reactions, and listen and respond to events. + +To listen for events happening in a Slack workspace (like when a message is posted or when a reaction is posted to a message) you'll use the [Events API to subscribe to event types](/apis/events-api/). + +For those just starting, we recommend using [Socket Mode](/apis/events-api/using-socket-mode). Socket Mode allows your app to use the Events API and interactive features without exposing a public HTTP Request URL. This can be helpful during development, or if you're receiving requests from behind a firewall. + +That being said, you're welcome to set up an app with a public HTTP Request URL. HTTP is more useful for apps being deployed to hosting environments to respond within a large corporate Slack workspaces/organization, or apps intended for distribution via the Slack Marketplace. + +We've provided instructions for both ways in this guide. + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +1. Head to your app's configuration page (click on the app [from your app settings page](https://api.slack.com/apps)). Navigate to **Socket Mode** on the left side menu and toggle to enable. + +2. Go to **Basic Information** and scroll down under the App-Level Tokens section and click **Generate Token and Scopes** to generate an app-level token. Add the `connections:write` scope to this token and save the generated `xapp` token, we'll use that in just a moment. + +3. Finally, it's time to tell Slack what events we'd like to listen for. Under **Event Subscriptions**, toggle the switch labeled **Enable Events**. + +When an event occurs, Slack will send your app some information about the event, like the user that triggered it and the channel it occurred in. Your app will process the details and can respond accordingly. + + + + +1. Go back to your app configuration page (click on the app [from your app management page](https://api.slack.com/apps)). Click **Event Subscriptions** on the left sidebar. Toggle the switch labeled **Enable Events**. + +2. Add your Request URL. Slack will send HTTP POST requests corresponding to events to this [Request URL](/apis/events-api/#subscribing) endpoint. Bolt uses the `/slack/events` path to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring your Request URL within your app configuration, you'll append `/slack/events`, e.g. `https:///slack/events`. 💡 As long as your Bolt app is still running, your URL should become verified. + +:::tip[Using proxy services] + +For local development, you can use a proxy service like ngrok to create a public URL and tunnel requests to your development environment. Refer to [ngrok's getting started guide](https://ngrok.com/docs#getting-started-expose) on how to create this tunnel. And when you get to hosting your app, we've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](/app-management/hosting-slack-apps). + +::: + + + + +Navigate to **Event Subscriptions** on the left sidebar and toggle to enable. Under **Subscribe to Bot Events**, you can add events for your bot to respond to. There are four events related to messages: +- [`message.channels`](/reference/events/message.channels) listens for messages in public channels that your app is added to. +- [`message.groups`](/reference/events/message.groups) listens for messages in 🔒 private channels that your app is added to. +- [`message.im`](/reference/events/message.im) listens for messages in your app's DMs with users. +- [`message.mpim`](/reference/events/message.mpim) listens for messages in multi-person DMs that your app is added to. + +If you want your bot to listen to messages from everywhere it is added to, choose all four message events. After you’ve selected the events you want your bot to listen to, click the green **Save Changes** button. + +--- + +### Listening and responding to a message {#listening-and-responding-to-a-message} +Your app is now ready for some logic. Let's start by using the `message()` method to attach a listener for messages. + +The following example listens and responds to all messages in channels/DMs where your app has been added that contain the word "hello": + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# Listens to incoming messages that contain "hello" +# To learn available listener arguments, +# visit https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say(f"Hey there <@{message['user']}>!") + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + + + + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +# To learn available listener arguments, +# visit https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say(f"Hey there <@{message['user']}>!") + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +If you restart your app, so long as your bot user has been added to the channel or DM conversation, when you send any message that contains "hello", it will respond. + +This is a basic example, but it gives you a place to start customizing your app based on your own goals. Let's try something a little more interactive by sending a button rather than plain text. + +--- + +### Sending and responding to actions {#sending-and-responding-to-actions} + +To use features like buttons, select menus, datepickers, modals, and shortcuts, you’ll need to enable interactivity. Head over to **Interactivity & Shortcuts** in your app configuration. + + + + +With Socket Mode on, basic interactivity is enabled by default, so no further action is needed. + + + + +Similar to events, you'll need to specify a URL for Slack to send the action (such as _user clicked a button_). Back on your app configuration page, click on **Interactivity & Shortcuts** on the left side. You'll see that there's another **Request URL** box. + +:::tip[By default, Bolt is configured to use the same endpoint for interactive components that it uses for events, so use the same request URL as above (for example, `https://8e8ec2d7.ngrok.io/slack/events`).] + +Press the **Save Changes** button in the lower right hand corner, and that's it. Your app is set up to handle interactivity! + +::: + + + + +When interactivity is enabled, interactions with shortcuts, modals, or interactive components (such as buttons, select menus, and datepickers) will be sent to your app as events. + +Now, let's go back to your app's code and add logic to handle those events: +- First, we'll send a message that contains an interactive component (in this case a button). +- Next, we'll listen for the action of a user clicking the button before responding. + +Below, the code from the last section is modified to send a message containing a button rather than just a string: + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + # signing_secret=os.environ.get("SLACK_SIGNING_SECRET") # not required for socket mode +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() + +``` + + + + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +The value inside of `say()` is now an object that contains an array of `blocks`. Blocks are the building components of a Slack message and can range from text to images to datepickers. In this case, your app will respond with a section block that includes a button as an accessory. Since we're using `blocks`, the `text` is a fallback for notifications and accessibility. + +You'll notice in the button `accessory` object, there is an `action_id`. This will act as a unique identifier for the button so your app can specify which action it wants to respond to. + +:::tip[Using Block Kit Builder] + +The [Block Kit Builder](https://app.slack.com/block-kit-builder) is an simple way to prototype your interactive messages. The builder lets you (or anyone on your team) mock up messages and generates the corresponding JSON that you can paste directly in your app. + +::: + +Now, if you restart your app and say "hello" in a channel your app is in, you'll see a message with a button. But if you click the button, nothing happens (_yet!_). + +Let's add a handler to send a follow-up message when someone clicks the button: + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Initializes your app with your bot token and socket mode handler +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # Acknowledge the action + ack() + say(f"<@{body['user']['id']}> clicked the button") + +# Start your app +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + + + + +```python +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# Listens to incoming messages that contain "hello" +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # Acknowledge the action + ack() + say(f"<@{body['user']['id']}> clicked the button") + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +You can see that we used `app.action()` to listen for the `action_id` that we named `button_click`. If you restart your app and click the button, you'll see a new message from your app that says you clicked the button. + +--- + +### Next steps {#next-steps} +You just built your first [Bolt for Python app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)! 🎉 + +Now that you have a basic app up and running, you can start exploring how to make your Bolt app stand out. Here are some ideas about what to explore next: + +* Read through the concepts pages to learn about the different methods and features your Bolt app has access to. + +* Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. View the full events reference docs [here](/reference/events). + +* Bolt allows you to [call Web API methods](/tools/bolt-python/concepts/web-api) with the client attached to your app. There are over 200 methods; view them [here](/reference/methods). + +* Learn more about the different token types in the [tokens guide](/authentication/tokens). Your app may need different tokens depending on the actions you want it to perform. \ No newline at end of file diff --git a/docs/english/experiments.md b/docs/english/experiments.md new file mode 100644 index 000000000..443a334be --- /dev/null +++ b/docs/english/experiments.md @@ -0,0 +1,11 @@ +# Experiments + +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 + +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 new file mode 100644 index 000000000..8cfd7faf8 --- /dev/null +++ b/docs/english/getting-started.md @@ -0,0 +1,288 @@ +--- +sidebar_label: Quickstart +title: Quickstart guide with Bolt for Python +--- + +This quickstart guide aims to help you get a Slack app using Bolt for Python up and running as soon as possible! + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +When complete, you'll have a local environment configured with a customized [app](https://github.com/slack-samples/bolt-python-getting-started-app) running to modify and make your own. + +:::tip[Reference for readers] + +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. + +::: + +#### Prerequisites + +A few tools are needed for the following steps. We recommend using the [**Slack CLI**](/tools/slack-cli/) for the smoothest experience, but other options remain available. + +You can also begin by installing git and downloading [Python 3.7 or later](https://www.python.org/downloads/), or the latest stable version of Python. Refer to [Python's setup and building guide](https://devguide.python.org/getting-started/setup-building/) for more details. + +Install the latest version of the Slack CLI to get started: + +- [Slack CLI for macOS & Linux](/tools/slack-cli/guides/installing-the-slack-cli-for-mac-and-linux) +- [Slack CLI for Windows](/tools/slack-cli/guides/installing-the-slack-cli-for-windows) + +Then confirm a successful installation with the following command: + +```sh +$ slack version +``` + +An authenticated login is also required if this hasn't been done before: + +```sh +$ slack login +``` + +:::info[A place to belong] + +A workspace where development can happen is also needed. + +We recommend using [developer sandboxes](/tools/developer-sandboxes) to avoid disruptions where real work gets done. + +::: + +## Creating a project {#creating-a-project} + +With the toolchain configured, it's time to set up a new Bolt project. This contains the code that handles logic for your app. + +If you don’t already have a project, let’s create a new one! + + + + +A starter template can be used to start with project scaffolding: + +```sh +$ slack create first-bolt-app --template slack-samples/bolt-python-getting-started-app +$ cd first-bolt-app +``` + +After a project is created you'll have a `requirements.txt` file for app dependencies and a `.slack` directory for Slack CLI configuration. + +A few other files exist too, but we'll visit these later. + + + + +A starter template can be cloned to start with project scaffolding: + +```sh +$ git clone https://github.com/slack-samples/bolt-python-getting-started-app first-bolt-app +$ cd first-bolt-app +``` + +Outlines of a project are taking shape, so we can move on to running the app! + + + + +We recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.7 or later](https://www.python.org/downloads/): + +```sh +$ python3 -m venv .venv +$ source .venv/bin/activate +$ pip install -r requirements.txt +``` + +Confirm the virtual environment is active by checking that the path to `python3` is _inside_ your project ([a similar command is available on Windows](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment)): + +```sh +$ which python3 +# Output: /path/to/first-bolt-app/.venv/bin/python3 +``` + +## Running the app {#running-the-app} + +Before you can start developing with Bolt, you will want a running Slack app. + + + + +The getting started app template contains a `manifest.json` file with details about an app that we will use to get started. Use the following command and select "Create a new app" to install the app to the team of choice: + +```sh +$ slack run +... +⚡️ Bolt app is running! +``` + +With the app running, you can test it out with the following steps in Slack: + +1. Open a direct message with your app or invite the bot `@first-bolt-app (local)` to a public channel. +2. Send "hello" to the current conversation and wait for a response. +3. Click the attached button labelled "Click Me" to post another reply. + +After confirming the app responds, celebrate, then interrupt the process by pressing `CTRL+C` in the terminal to stop your app from running. + + + + +Navigate to your list of apps and [create a new Slack app](https://api.slack.com/apps/new) using the "from a manifest" option: + +1. Select the workspace to develop your app in. +2. Copy and paste the `manifest.json` file contents to create your app. +3. Confirm the app features and click "Create". + +You'll then land on your app's **Basic Information** page, which is an overview of your app and which contains important credentials: + +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information page") + +To listen for events happening in Slack (such as a new posted message) without opening a port or exposing an endpoint, we will use [Socket Mode](/tools/bolt-python/concepts/socket-mode). This connection requires a specific app token: + +1. On the **Basic Information** page, scroll to the **App-Level Tokens** section and click **Generate Token and Scopes**. +2. Name the token "Development" or something similar and add the `connections:write` scope, then click **Generate**. +3. Save the generated `xapp` token as an environment variable within your project: + +```sh +$ export SLACK_APP_TOKEN= +``` + +The above command works on Linux and macOS but [similar commands are available on Windows](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153). + +:::warning[Keep it secret. Keep it safe.] + +Treat your tokens like a password and [keep it safe](/concepts/security). Your app uses these to retrieve and send information to Slack. + +::: + +A bot token is also needed to interact with the Web API methods as your app's bot user. We can gather this as follows: + +1. Navigate to the **OAuth & Permissions** on the left sidebar and install your app to your workspace to generate a token. +2. After authorizing the installation, you'll return to the **OAuth & Permissions** page and find a **Bot User OAuth Token**: + +![OAuth Tokens](/img/bolt-python/bot-token.png "Bot OAuth Token") + +3. Copy the bot token beginning with `xoxb` from the **OAuth & Permissions page** and then store it in a new environment variable: + +```sh +$ export SLACK_BOT_TOKEN=xoxb- +``` + +After saving tokens for the app you created, it is time to run it: + +```sh +$ python3 app.py +... +⚡️ Bolt app is running! +``` + +With the app running, you can test it out with the following steps in Slack: + +1. Open a direct message with your app or invite the bot `@BoltApp` to a public channel. +2. Send "hello" to the current conversation and wait for a response. +3. Click the attached button labelled "Click Me" to post another reply. + +After confirming the app responds, celebrate, then interrupt the process by pressing `CTRL+C` in the terminal to stop your app from running. + + + + +## Updating the app + +At this point, you've successfully run the getting started Bolt for Python [app](https://github.com/slack-samples/bolt-python-getting-started-app)! + +The defaults included leave opportunities abound, so to personalize this app let's now edit the code to respond with a kind farewell. + +#### Responding to a farewell + +Chat is a common thing apps do and responding to various types of messages can make conversations more interesting. + +Using an editor of choice, open the `app.py` file and add the following import to the top of the file, and message listener after the "hello" handler: + +```python +import random + +@app.message("goodbye") +def message_goodbye(say): + responses = ["Adios", "Au revoir", "Farewell"] + parting = random.choice(responses) + say(f"{parting}!") +``` + +Once the file is updated, save the changes and then we'll make sure those changes are being used. + + + + +Run the following command and select the app created earlier to start, or restart, your app with the latest changes: + +```sh +$ slack run +... +⚡️ Bolt app is running! +``` + +After finding the above output appears, open Slack to perform these steps: + +1. Return to the direct message or public channel with your bot. +2. Send "goodbye" to the conversation. +3. Receive a parting response from before and repeat "goodbye" to find another one. + +Your app can be stopped again by pressing `CTRL+C` in the terminal to end these chats. + + + + +Run the following command to start, or restart, your app with the latest changes: + +```sh +$ python3 app.py +... +⚡️ Bolt app is running! +``` + +After finding the above output appears, open Slack to perform these steps: + +1. Return to the direct message or public channel with your bot. +2. Send "goodbye" to the conversation. +3. Receive a parting response from before and repeat "goodbye" to find another one. + +Your app can be stopped again by pressing `CTRL+C` in the terminal to end these chats. + + + + +#### Customizing app settings + +The created app will have some placeholder values and a small set of [scopes](/reference/scopes) to start, but we recommend exploring the customizations possible on app settings. + + + + +Open app settings for your app with the following command: + +```sh +$ slack app settings +``` + +This will open the following page in a web browser: + +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information page") + + + + +Browse to https://api.slack.com/apps and select your app "Getting Started Bolt App" from the list. + +This will open the following page: + +![Basic Information page](/img/bolt-python/basic-information-page.png "Basic Information 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! + +## Next steps {#next-steps} + +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: + +- 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 new file mode 100644 index 000000000..60df4ec1c --- /dev/null +++ b/docs/english/index.md @@ -0,0 +1,25 @@ +# Bolt for Python + +Bolt for Python is a Python framework to build Slack apps with the latest Slack platform features. Read the [Getting Started Guide](/tools/bolt-python/getting-started) to set up and run your first Bolt app. + +Then, explore the rest of the pages within the Guides section. The documentation there will help you build a Bolt app for whatever use case you may have. + +## Getting help + +These docs have lots of information on Bolt for Python. There's also an in-depth Reference section. Please explore! + +If you otherwise get stuck, we're here to help. The following are the best ways to get assistance working through your issue: + +* [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. + +We welcome contributions from everyone! Please check out our +[Contributor's Guide](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) for how to contribute in a helpful and collaborative way. \ No newline at end of file diff --git a/docs/english/legacy/steps-from-apps.md b/docs/english/legacy/steps-from-apps.md new file mode 100644 index 000000000..bced20f9e --- /dev/null +++ b/docs/english/legacy/steps-from-apps.md @@ -0,0 +1,195 @@ +# Steps from apps + +:::danger[Steps from Apps is a deprecated feature.] + +Steps from Apps are different than, and not interchangeable with, Slack automation workflows. We encourage those who are currently publishing steps from apps to consider the new [Slack automation features](/workflows/), such as [custom steps for Bolt](/workflows/workflow-steps). + +Please [read the Slack API changelog entry](/changelog/2023-08-workflow-steps-from-apps-step-back) for more information. + +::: + +Steps from apps allow your app to create and process steps that users can add using [Workflow Builder](/workflows/workflow-builder). + +Steps from apps are made up of three distinct user events: + +- Adding or editing the step in a Workflow +- Saving or updating the step's configuration +- The end user's execution of the step + +All three events must be handled for a step from app to function. + +Read more about steps from apps in the [API documentation](/workflows/workflow-steps). + +## Creating steps from apps + +To create a step from app, Bolt provides the `WorkflowStep` class. + +When instantiating a new `WorkflowStep`, pass in the step's `callback_id` and a configuration object. + +The configuration object contains three keys: `edit`, `save`, and `execute`. Each of these keys must be a single callback or a list of callbacks. All callbacks have access to a `step` object that contains information about the step from app event. + +After instantiating a `WorkflowStep`, you can pass it into `app.step()`. Behind the scenes, your app will listen and respond to the step’s events using the callbacks provided in the configuration object. + +Alternatively, steps from apps can also be created using the `WorkflowStepBuilder` class alongside a decorator pattern. For more information, including an example of this approach, [refer to the documentation](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/step.html#slack_bolt.workflows.step.step.WorkflowStepBuilder). + +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. + +```python +import os +from slack_bolt import App +from slack_bolt.workflows.step import WorkflowStep + +# Initiate the Bolt app as you normally would +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +def edit(ack, step, configure): + pass + +def save(ack, view, update): + pass + +def execute(step, complete, fail): + pass + +# Create a new WorkflowStep instance +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) + +# Pass Step to set up listeners +app.step(ws) +``` + +## Adding or editing steps from apps + +When a builder adds (or later edits) your step in their workflow, your app will receive a `workflow_step_edit` event. The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. + +Whether a builder is adding or editing a step, you need to send them a step from app configuration modal. This modal is where step-specific settings are chosen, and it has more restrictions than typical modals—most notably, it cannot include `title`, `submit`, or `close` properties. By default, the configuration modal's `callback_id` will be the same as the step from app. + +Within the `edit` callback, the `configure()` utility can be used to easily open your step's configuration modal by passing in the view's blocks with the corresponding `blocks` argument. To disable saving the configuration before certain conditions are met, you can also pass in `submit_disabled` with a value of `True`. + +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. + +```python +def edit(ack, step, configure): + ack() + + blocks = [ + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "name", + "placeholder": {"type": "plain_text", "text": "Add a task name"}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "description", + "placeholder": {"type": "plain_text", "text": "Add a task description"}, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + ] + configure(blocks=blocks) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` + +## Saving step configurations + +After the configuration modal is opened, your app will listen for the `view_submission` event. The `save` callback in your `WorkflowStep` configuration will be run when this event is received. + +Within the `save` callback, the `update()` method can be used to save the builder's step configuration by passing in the following arguments: + +- `inputs` is a dictionary representing the data your app expects to receive from the user upon step execution. +- `outputs` is a list of objects containing data that your app will provide upon the step's completion. Outputs can then be used in subsequent steps of the workflow. +- `step_name` overrides the default Step name +- `step_image_url` overrides the default Step image + +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. + +```python +def save(ack, view, update): + ack() + + values = view["state"]["values"] + task_name = values["task_name_input"]["name"] + task_description = values["task_description_input"]["description"] + + inputs = { + "task_name": {"value": task_name["value"]}, + "task_description": {"value": task_description["value"]} + } + outputs = [ + { + "type": "text", + "name": "task_name", + "label": "Task name", + }, + { + "type": "text", + "name": "task_description", + "label": "Task description", + } + ] + update(inputs=inputs, outputs=outputs) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` + +## Executing steps from apps + +When your step from app is executed by an end user, your app will receive a `workflow_step_execute` event. The `execute` callback in your `WorkflowStep` configuration will be run when this event is received. + +Using the `inputs` from the `save` callback, this is where you can make third-party API calls, save information to a database, update the user's Home tab, or decide the outputs that will be available to subsequent steps from apps by mapping values to the `outputs` object. + +Within the `execute` callback, your app must either call `complete()` to indicate that the step's execution was successful, or `fail()` to indicate that the step's execution failed. + +Refer to the module documents ([common](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [step-specific](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) to learn the available arguments. + +```python +def execute(step, complete, fail): + inputs = step["inputs"] + # if everything was successful + outputs = { + "task_name": inputs["task_name"]["value"], + "task_description": inputs["task_description"]["value"], + } + complete(outputs=outputs) + + # if something went wrong + error = {"message": "Just testing step failure!"} + fail(error=error) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` diff --git a/docs/english/tutorial/ai-chatbot/1.png b/docs/english/tutorial/ai-chatbot/1.png new file mode 100644 index 000000000..7198bc235 Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/1.png differ diff --git a/docs/english/tutorial/ai-chatbot/2.png b/docs/english/tutorial/ai-chatbot/2.png new file mode 100644 index 000000000..fe29f2407 Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/2.png differ diff --git a/docs/english/tutorial/ai-chatbot/3.png b/docs/english/tutorial/ai-chatbot/3.png new file mode 100644 index 000000000..fbf795ad8 Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/3.png differ diff --git a/docs/english/tutorial/ai-chatbot/4.png b/docs/english/tutorial/ai-chatbot/4.png new file mode 100644 index 000000000..c004fa465 Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/4.png differ diff --git a/docs/english/tutorial/ai-chatbot/5.png b/docs/english/tutorial/ai-chatbot/5.png new file mode 100644 index 000000000..7beede412 Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/5.png differ diff --git a/docs/english/tutorial/ai-chatbot/6.png b/docs/english/tutorial/ai-chatbot/6.png new file mode 100644 index 000000000..e70c9714e Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/6.png differ diff --git a/docs/english/tutorial/ai-chatbot/7.png b/docs/english/tutorial/ai-chatbot/7.png new file mode 100644 index 000000000..9d0b94976 Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/7.png differ diff --git a/docs/english/tutorial/ai-chatbot/8.png b/docs/english/tutorial/ai-chatbot/8.png new file mode 100644 index 000000000..bb502e539 Binary files /dev/null and b/docs/english/tutorial/ai-chatbot/8.png differ diff --git a/docs/english/tutorial/ai-chatbot/ai-chatbot.md b/docs/english/tutorial/ai-chatbot/ai-chatbot.md new file mode 100644 index 000000000..2fcc16e9a --- /dev/null +++ b/docs/english/tutorial/ai-chatbot/ai-chatbot.md @@ -0,0 +1,245 @@ +# 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. + +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} + +You will also need the following: + +- 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. + +### 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. + +#### Provider tokens {#provider-tokens} + +Models from different AI providers are available if the corresponding environment variable is added as shown in the sections below. + + + + +To interact with Anthropic models, navigate to your Anthropic account dashboard to [create an API key](https://console.anthropic.com/settings/keys), then export the key as follows: + +```bash +export ANTHROPIC_API_KEY= +``` + + + + +To use Google Cloud Vertex AI, [follow this quick start](https://cloud.google.com/vertex-ai/generative-ai/docs/start/quickstarts/quickstart-multimodal#expandable-1) to create a project for sending requests to the Gemini API, then gather [Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc) with the strategy to match your development environment. + +Once your project and credentials are configured, export environment variables to select from Gemini models: + +```bash +export VERTEX_AI_PROJECT_ID= +export VERTEX_AI_LOCATION= +``` + +The project location can be located under the **Region** on the [Vertex AI](https://console.cloud.google.com/vertex-ai) dashboard, as well as more details about available Gemini models. + + + + +Unlock the OpenAI models from your OpenAI account dashboard by clicking [create a new secret key](https://platform.openai.com/api-keys), then export the key like so: + +```bash +export OPENAI_API_KEY= +``` + + + + +## Setting up and running your local project {#configure-project} + + +Start your Python virtual environment: + + + + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + + + + +```bash +py -m venv .venv +.venv\Scripts\activate +``` + + + + +Install the required dependencies: + +```bash +pip install -r requirements.txt +``` + +Run your app locally: + +```bash +slack run +``` + +If your app is indeed up and running, you'll see a message that says "⚡️ Bolt app is running!" + +## Choosing your provider {#provider} + +Navigate to the Bolty **App Home** and select a provider from the drop-down menu. The options listed will be dependent on which secret keys you added when setting your environment variables. + +If you don't see Bolty listed under **Apps** in your workspace right away, never fear! You can mention **@Bolty** in a public channel to add the app, then navigate to your **App Home**. + +![Choose your AI provider](6.png) + +## Setting up your workflow {#workflow} + +Within your development workspace, open Workflow Builder by clicking on your workspace name and then **Tools > Workflow Builder**. Select **New Workflow** > **Build Workflow**. + +Click **Untitled Workflow** at the top to rename your workflow. For this tutorial, we'll call the workflow **Welcome to the channel**. Enter a description, such as _Summarizes channels for new members_, and click **Save**. + +![Setting up a new workflow](1.png) + +Select **Choose an event** under **Start the workflow...**, and then choose **When a person joins a channel**. Select the channel name from the drop-down menu and click **Save**. + +![Start the workflow](2.png) + +Under **Then, do these things**, click **Add steps** and complete the following: + +1. Select **Messages** > **Send a message to a person**. +2. Under **Select a member**, choose **The user who joined the channel** from the drop-down menu. +3. Under **Add a message**, enter a short message, such as _Hi! Welcome to `{}The channel that the user joined`. Would you like a summary of the recent conversation?_ Note that the _`{}The channel that the user joined`_ is a variable; you can insert it by selecting **{}Insert a variable** at the bottom of the message text box. +4. Select the **Add Button** button, and name the button _Yes, give me a summary_. Click **Done**. + +![Send a message](3.png) + +We'll add two more steps under the **Then, do these things** section. + +First, scroll to the bottom of the list of steps and choose **Custom**, then choose **Bolty** and **Bolty Custom Function**. In the **Channel** drop-down menu, select **Channel that the user joined**. Click **Save**. + +![Bolty custom function](4.png) + +For the final step, complete the following: + +1. Choose **Messages** and then **Send a message to a person**. Under **Select a member**, choose **Person who clicked the button** from the drop-down menu. +2. Under **Add a message**, click **Insert a variable** and choose **`{}Summary`** under the **Bolty Custom Function** section in the list that appears. Click **Save**. + +![Summary](5.png) + +When finished, click **Finish Up**, then click **Publish** to make the workflow available in your workspace. + +## Interacting with Bolty {#interact} + +### Summarizing recent conversations {#summarize} + +In order for Bolty to provide summaries of recent conversation in a channel, Bolty _must_ be a member of that channel. + +1. Invite Bolty to a channel that you are able to leave and rejoin (for example, not the **#general** channel or a private channel someone else created) by mentioning the app in the channel — i.e., tagging **@Bolty** in the channel and sending your message. +2. Slackbot will prompt you to either invite Bolty to the channel, or do nothing. Click **Invite Them**. Now when new users join the channel, the workflow you just created will be kicked off. + +To test this, leave the channel you just invited Bolty to and rejoin it. This will kick off your workflow and you'll receive a direct message from **Welcome to the channel**. Click the **Yes, give me a summary** button, and Bolty will summarize the recent conversations in the channel you joined. + +![Channel summary](7.png) + +The central part of this functionality is shown in the following code snippet. Note the use of the [`user_context`](/tools/deno-slack-sdk/reference/slack-types#usercontext) object, a Slack type that represents the user who is interacting with our workflow, as well as the `history` of the channel that will be summarized, which includes the ten most recent messages. + +```python +from ai.providers import get_provider_response +from logging import Logger +from slack_bolt import Complete, Fail, Ack +from slack_sdk import WebClient +from ..listener_utils.listener_constants import SUMMARIZE_CHANNEL_WORKFLOW +from ..listener_utils.parse_conversation import parse_conversation + +""" +Handles the event to summarize a Slack channel's conversation history. +It retrieves the conversation history, parses it, generates a summary using an AI response, +and completes the workflow with the summary or fails if an error occurs. +""" + +def handle_summary_function_callback( + ack: Ack, inputs: dict, fail: Fail, logger: Logger, client: WebClient, complete: Complete +): + ack() + try: + user_context = inputs["user_context"] + channel_id = inputs["channel_id"] + history = client.conversations_history(channel=channel_id, limit=10)["messages"] + conversation = parse_conversation(history) + + summary = get_provider_response(user_context["id"], SUMMARIZE_CHANNEL_WORKFLOW, conversation) + + complete({"user_context": user_context, "response": summary}) + except Exception as e: + logger.exception(e) + fail(e) +``` + +### Asking Bolty a question {#ask-app} + +To ask Bolty a question, you can chat with Bolty in any channel the app is in. Use the `\ask-bolty` slash command to provide a prompt for Bolty to answer. Note that Bolty is currently not supported in threads. + +You can also navigate to **Bolty** in your **Apps** list and select the **Messages** tab to chat with Bolty directly. + +![Ask Bolty](8.png) + +## Next steps {#next-steps} + +Congratulations! You've successfully integrated the power of AI into your workspace. Check out these links to take the next steps in your Bolt for Python journey. + +- To learn more about Bolt for Python, refer to the [Getting started](/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. \ No newline at end of file diff --git a/docs/english/tutorial/custom-steps-for-jira/1.png b/docs/english/tutorial/custom-steps-for-jira/1.png new file mode 100644 index 000000000..5d8bb0448 Binary files /dev/null and b/docs/english/tutorial/custom-steps-for-jira/1.png differ diff --git a/docs/english/tutorial/custom-steps-for-jira/2.png b/docs/english/tutorial/custom-steps-for-jira/2.png new file mode 100644 index 000000000..67e55c65d Binary files /dev/null and b/docs/english/tutorial/custom-steps-for-jira/2.png differ diff --git a/docs/english/tutorial/custom-steps-for-jira/3.png b/docs/english/tutorial/custom-steps-for-jira/3.png new file mode 100644 index 000000000..76829fcd7 Binary files /dev/null and b/docs/english/tutorial/custom-steps-for-jira/3.png differ diff --git a/docs/english/tutorial/custom-steps-for-jira/4.png b/docs/english/tutorial/custom-steps-for-jira/4.png new file mode 100644 index 000000000..ac4d3e89a Binary files /dev/null and b/docs/english/tutorial/custom-steps-for-jira/4.png differ diff --git a/docs/english/tutorial/custom-steps-for-jira/5.png b/docs/english/tutorial/custom-steps-for-jira/5.png new file mode 100644 index 000000000..c68db2c86 Binary files /dev/null and b/docs/english/tutorial/custom-steps-for-jira/5.png differ diff --git a/docs/english/tutorial/custom-steps-for-jira/6.png b/docs/english/tutorial/custom-steps-for-jira/6.png new file mode 100644 index 000000000..e7cc1f0ca Binary files /dev/null and b/docs/english/tutorial/custom-steps-for-jira/6.png differ diff --git a/docs/english/tutorial/custom-steps-for-jira/7.png b/docs/english/tutorial/custom-steps-for-jira/7.png new file mode 100644 index 000000000..0b10523a3 Binary files /dev/null and b/docs/english/tutorial/custom-steps-for-jira/7.png differ diff --git a/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md b/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md new file mode 100644 index 000000000..d74b82b8e --- /dev/null +++ b/docs/english/tutorial/custom-steps-for-jira/custom-steps-for-jira.md @@ -0,0 +1,172 @@ +# Custom steps for JIRA + +In this tutorial, you'll learn how to configure custom steps for use with JIRA. Here's what we'll do with this sample app: + +1. Create your app from an app manifest and clone a starter template +2. Set up and run your local project +3. Create a workflow with a custom step using Workflow Builder +4. Create an issue in JIRA using your custom step + +## Prerequisites {#prereqs} + +Before getting started, you will need the following: + +* a development workspace where you have permissions to install apps. If you don’t have a workspace, go ahead and set that up now—you can [go here](https://slack.com/get-started#create) to create one, or you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +* a development environment with [Python 3.7](https://www.python.org/downloads/) or later. + +**Skip to the code** +If you'd rather skip the tutorial and just head straight to the code, you can use our [Bolt for Python JIRA functions sample](https://github.com/slack-samples/bolt-python-jira-functions) as a template. + +## Creating your app {#create-app} + +1. Navigate to the [app creation page](https://api.slack.com/apps/new) and select **From a manifest**. +2. Select the workspace you want to install the application in, then click **Next**. +3. Copy the contents of the [`manifest.json`](https://github.com/slack-samples/bolt-python-jira-functions/blob/main/manifest.json) file below into the text box that says **Paste your manifest code here** (within the **JSON** tab), then click **Next**: + +```js reference title="manifest.json" +https://github.com/slack-samples/bolt-python-jira-functions/blob/main/manifest.json +``` + +4. Review the configuration and click **Create**. +5. You're now in your app configuration's **Basic Information** page. Click **Install App**, then **Install to _your-workspace-name_**, then **Allow** on the screen that follows. + +### Obtaining and storing your environment variables {#environment-variables} + +Before you'll be able to successfully run the app, you'll need to obtain and set some environment variables. + +1. Once you have installed the app to your workspace, copy the **Bot User OAuth Token** from the **Install App** page. You will store this in your environment as `SLACK_BOT_TOKEN` (we'll get to that next). +2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](/reference/scopes/connections.write) scope, name the token, and click **Generate**. Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. +3. Follow [these instructions](https://confluence.atlassian.com/adminjiraserver0909/configure-an-incoming-link-1251415519.html) to create an external app link and to generate its redirect URL (the base of which will be stored as your APP_BASE_URL variable below), client ID, and client secret. +4. Run the following commands in your terminal to store your environment variables, client ID, and client secret. +5. You'll also need to know your team ID (found by opening your Slack instance in a web browser and copying the value within the link that starts with the letter **T**) and your app ID (found under **Basic Information**). + +**For macOS** +```bash +export SLACK_BOT_TOKEN= +export SLACK_APP_TOKEN= +export JIRA_CLIENT_ID= +export JIRA_CLIENT_SECRET= +``` + +**For Windows** +```bash +set SLACK_BOT_TOKEN= +set SLACK_APP_TOKEN= +set JIRA_CLIENT_ID= +set JIRA_CLIENT_SECRET= +``` + +## Setting up and running your local project {#configure-project} + +Clone the starter template onto your machine by running the following command: + +```bash +git clone https://github.com/slack-samples/bolt-python-jira-functions.git +``` + +Change into the new project directory: + +```bash +cd bolt-python-jira-functions +``` + +Start your Python virtual environment: + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + + + + +```bash +py -m venv .venv +.venv\Scripts\activate +``` + + + +Install the required dependencies: + +```bash +pip install -r requirements.txt +``` + +Rename the `.example.env` file to `.env` and replace the values for each of the variables listed in the file: + +``` +JIRA_BASE_URL=https://your-jira-instance.com +SECRET_HEADER_KEY=Your-Header +SECRET_HEADER_VALUE=abc123 +JIRA_CLIENT_ID=abc123 +JIRA_CLIENT_SECRET=abc123 +APP_BASE_URL=https://1234-123-123-12.ngrok-free.app +APP_HOME_PAGE_URL=slack://app?team=YOUR_TEAM_ID&id=YOUR_APP_ID&tab=home +``` + +You could also store the values for your `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` here. + +Start your local server: + +```bash +python app.py +``` + +If your app is up and running, you'll see a message noting that the app is starting to receive messages from a new connection. + +## Setting up your workflow in Workflow Builder {#workflow} + +1. Within your development workspace, open Workflow Builder by clicking your workspace name and then selecting **Tools** > **Workflow Builder**. +2. Select **New Workflow** > **Build Workflow**. +3. Click **Untitled Workflow** at the top of the pane to rename your workflow. We'll call it **Create Issue**. For the description, enter _Creates a new issue_, then click **Save**. + +![Workflow details](1.png) + +4. Select **Choose an event** under **Start the workflow...**, and then select **From a link in Slack**. Click **Continue**. + +![Start the workflow](2.png) + +5. Under **Then, do these things** click **Add steps** to add the custom step. Your custom step will be the function defined in the [`create_issue.py`](https://github.com/slack-samples/bolt-python-jira-functions/blob/main/listeners/functions/create_issue.py) file. + + Scroll down to the bottom of the list on the right-hand pane and select **Custom**, then **BoltPy Jira Functions** > **Create an issue**. Enter the project details, issue type (optional), summary (optional), and description (optional). Click **Save**. + +![Custom function](3.png) + +6. Add another step and select **Messages** > **Send a message to a channel**. Select **Channel where the workflow was used** from the drop-down list and then select **Insert a variable** and **Issue url**. Click **Save**. + +![Insert variable for issue URL](4.png) + +7. Click **Publish** to make the workflow available to your workspace. + +## Running your app {#run} + +1. Copy your workflow link. +2. Navigate to your app's home tab and click **Connect an Account** to connect your JIRA account to the app. + +![Connect account](5.png) + +3. Click **Allow** on the screen that appears. + +![Allow connection](6.png) + +4. In any channel, post the workflow link you copied. +5. Click **Start Workflow** and observe as the link to a new JIRA ticket is posted in the channel. Click the link to be directed to the newly-created issue within your JIRA project. + +![JIRA issue](7.png) + +When finished, you can click the **Disconnect Account** button in the home tab to disconnect your app from your JIRA account. + +## Next steps {#next-steps} + +Congratulations! You've successfully customized your workspace with custom steps in Workflow Builder. Check out these links to take the next steps in your journey. + +* To learn more about Bolt for Python, refer to the [getting started](/tools/bolt-python/getting-started) documentation. +* For more details about creating workflow steps using the Bolt SDK, refer to the [workflow steps for Bolt](/workflows/workflow-steps) guide. +* For information about custom steps dynamic options, refer to [custom steps dynamic options in Workflow Builder](/tools/bolt-python/concepts/custom-steps-dynamic-options). diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/add-step.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/add-step.png new file mode 100644 index 000000000..81b32d5e0 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/add-step.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/app-message.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/app-message.png new file mode 100644 index 000000000..a8420a6b5 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/app-message.png differ 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 new file mode 100644 index 000000000..9a5e3ee50 --- /dev/null +++ b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md @@ -0,0 +1,280 @@ +# Custom Steps for Workflow Builder (existing app) + +:::info[This feature requires a paid plan] +If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +::: + +If you followed along with our [create a custom step for Workflow Builder: new app](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) tutorial, you have seen how to add custom steps to a brand new app. But what if you have an app up and running currently to which you'd like to add custom steps? You've come to the right place! + +In this tutorial we will: +- Start with an existing Bolt app +- Add a custom **workflow step** in the [app settings](https://api.slack.com/apps) +- Wire up the new step to a **function listener** in our project, using the [Bolt for Python](https://docs.slack.dev/tools/bolt-python/) framework +- See the step as a custom workflow step in Workflow Builder + +## Prerequisites {#prereqs} + +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 +source .venv/bin/activate +pip install -r requirements.txt +``` + +In order to add custom workflow steps to an app, the app also needs to be org-ready. To do this, navigate to your [app settings page](https://api.slack.com/apps) and select your Bolt app. + +Navigate to **Org Level Apps** in the left nav and click **Opt-In**, then confirm **Yes, Opt-In**. + +![Make your app org-ready](org-ready.png) + +## Adding a new workflow step {#add-step} + +Before we can add the new workflow step, we first need to ensure the workflow step is listening for the `function_executed` event so that our app knows when the workflow step is executed. + +### Adding the `function_executed` event subscription {#event-subscription} + +Navigate to **App Manifest** in the left nav and add the `function_executed` event subscription, then click **Save Changes**: + +```json +... + "settings": { + "event_subscriptions": { + "bot_events": [ + ... + "function_executed" + ] + }, + } +``` + +### Adding the workflow step {#add-step} + +Navigate to **Workflow Steps** in the left nav and click **Add Step**. This is where we'll configure our step's inputs, outputs, name, and description. + +![Add step](add-step.png) + +For illustration purposes in this tutorial, we're going to write a custom step called Request Time Off. When the step is invoked, a message will be sent to the provided manager with an option to approve or deny the time-off request. When the manager takes an action (approves or denies the request), a message is posted with the decision and the manager who made the decision. The step will take two user IDs as inputs, representing the requesting user and their manager, and it will output both of those user IDs as well as the decision made. + +Add the pertinent details to the step: + +![Define step](define-step.png) + +Remember this `callback_id`. We will use this later when implementing a function listener. Then add the input and output parameters: + +![Add inputs](inputs.png) + +![Add outputs](outputs.png) + +Save your changes. + +### Viewing our updates in the App Manifest {#view-updates} + +Navigate to **App Manifest** and notice your new step reflected in the `functions` property! Exciting. It should look like this: + +```json +"functions": { + "request_time_off": { + "title": "Request time off", + "description": "Submit a request to take time off", + "input_parameters": { + "manager_id": { + "type": "slack#/types/user_id", + "title": "Manager", + "description": "Approving manager", + "is_required": true, + "hint": "Select a user in the workspace", + "name": "manager_id" + }, + "submitter_id": { + "type": "slack#/types/user_id", + "title": "Submitting user", + "description": "User that submitted the request", + "is_required": true, + "name": "submitter_id" + } + }, + "output_parameters": { + "manager_id": { + "type": "slack#/types/user_id", + "title": "Manager", + "description": "Approving manager", + "is_required": true, + "name": "manager_id" + }, + "request_decision": { + "type": "boolean", + "title": "Request decision", + "description": "Decision to the request for time off", + "is_required": true, + "name": "request_decision" + }, + "submitter_id": { + "type": "slack#/types/user_id", + "title": "Submitting user", + "description": "User that submitted the request", + "is_required": true, + "name": "submitter_id" + } + } + } + } +``` + +Next, we'll define a function listener to handle what happens when the workflow step is used. + +## Adding function and action listeners {#adding-listeners} + +### Implementing the function listener {#function-listener} + +Direct your attention back to your app project in VSCode or your preferred code editor. Here we'll add logic that your app will execute when the custom step is executed. + +Open your `app.py` file and add the following function listener code for the `request_time_off` step. + +```py +@app.function("request_time_off") +def handle_request_time_off(inputs: dict, fail: Fail, logger: logging.Logger, say: Say): + + submitter_id = inputs["submitter_id"] + manager_id = inputs["manager_id"] + + try: + say( + channel=manager_id, + text=f"<@{submitter_id}> requested time off! What say you?", + blocks=[ + { + "type": 'section', + "text": { + "type": 'mrkdwn', + "text": f"<@{submitter_id}> requested time off! What say you?", + }, + }, + { + 'type': 'actions', + 'elements': [ + { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Approve', + 'emoji': True, + }, + 'value': 'approve', + 'action_id': 'approve_button', + }, + { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Deny', + 'emoji': True, + }, + 'value': 'deny', + 'action_id': 'deny_button', + }, + ], + }, + ], + ) + except Exception as e: + logger.exception(e) + fail(f"Failed to handle a function request (error: {e})") +``` + +#### Anatomy of a `.function()` listener {#function-listener-anatomy} + +The function decorator (`function()`) accepts an argument of type `str` and is the unique callback ID of the step. For our custom step, we’re using `request_time_off`. Every custom step you implement in an app needs to have a unique callback ID. + +The callback function is where we define the logic that will run when Slack tells the app that a user in the Slack client started a workflow that contains the `request_time_off` custom step. + +The callback function offers various utilities that can be used to take action when a function execution event is received. The ones we’ll be using here are: + +* `inputs` provides access to the workflow variables passed into the step when the workflow was started +* `fail` indicates when the step invoked for the current workflow step has an error +* `logger` provides a Python standard logger instance +* `say` calls the `chat.Postmessage` API method + +### Implementing the action listener {#action-listener} + +This custom step also requires an action listener to respond to the action of a user clicking a button. + +In that same `app.py` file, add the following action listener: + +```py +@app.action(re.compile("(approve_button|deny_button)")) +def manager_resp_handler(ack: Ack, action, body: dict, client: WebClient, complete: Complete, fail: Fail, logger: logging.Logger): + + ack() + + try: + inputs = body['function_data']['inputs'] + manager_id = inputs['manager_id'] + submitter_id = inputs['submitter_id'] + request_decision = action['value'] + + client.chat_update( + channel=body['channel']['id'], + ts=body["message"]["ts"], + text=f"Request {'approved' if request_decision == 'approve' else 'denied'}!" + ) + + complete({ + 'manager_id': manager_id, + 'submitter_id': submitter_id, + 'request_decision': request_decision == 'approve' + }) + + except Exception as e: + logger.exception(e) + fail(f"Failed to handle a function request (error: {e})") +``` + +#### Anatomy of an `.action()` listener {#action-listener-anatomy} + +Similar to a function listener, the action listener registration method (`.action()`) takes two arguments: + +- The first argument is the unique callback ID of the action that your app will respond to. In our case, because we want to execute the same logic for both buttons, we’re using a little bit of RegEx magic to listen for two callback IDs at the same time — `approve_button` and `deny_button`. +- The second argument is an asynchronous callback function, where we define the logic that will run when Slack tells our app that the manager has clicked or tapped the Approve button or the Deny button. + +Just like the function listener’s callback function, the action listener’s callback function offers various utilities that can be used to take action when an action event is received. The ones we’ll be using here are: +- `client`, which provides access to Slack API methods +- `action`, which provides the action’s event payload +- `complete`, which is a utility method indicating to Slack that the step behind the workflow step that was just invoked has completed successfully +- `fail`, which is a utility method for indicating that the step invoked for the current workflow step had an error + +Slack will send an action event payload to your app when one of the buttons is clicked or tapped. In the action listener, we’ll extract all the information we can use, and if all goes well, let Slack know the step was successful by invoking complete. We’ll also handle cases where something goes wrong and produces an error. + +Now that the custom step has been added to the app and we've defined step and action listeners for it, we're ready to see the step in action in Workflow Builder. Go ahead and run your app to pick up the changes. + +### Creating a workflow with the new step {#add-new-step} + +Turn your attention to the Slack client where your app is installed. + +Open Workflow Builder by clicking on the workspace name, then **Tools**, then **Workflow Builder**. + +Click the button to create a **New Workflow**, then **Build Workflow**. Choose to start your workflow **from a link in Slack**. + +In the **Steps** pane to the right, search for your app name and locate the **Request time off** step we created. + +![Find step](find-step.png) + +Select the step and choose the desired inputs and click **Save**. + +![Step inputs](step-inputs.png) + +Next, click **Finish Up**, give your workflow a name and description, then click **Publish**. Copy the link for your workflow on the next screen, then click **Done**. + +### Running the workflow {#run-workflow} + +In any channel where your app is installed, paste the link you copied and send it as a message. The link will unfurl into a button to start the workflow. Click the button to start the workflow. If you set yourself up as the manager, you will then see a message from your app. Pressing either button will return a confirmation or denial of your time off request. + +![Message](app-message.png) + +## Next steps {#next-steps} + +Nice work! Now that you've added a workflow step to your Bolt app, a world of possibilities is open to you! Create and share workflow steps across your organization to optimize Slack users' time and make their working lives more productive. + +If you're looking to create a brand new Bolt app with custom workflow steps, check out [the tutorial here](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new). + +If you're interested in exploring how to create custom steps to use in Workflow Builder as steps with our Deno Slack SDK, too, that tutorial can be found [here](/tools/deno-slack-sdk/tutorials/workflow-builder-custom-step/). diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/define-step.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/define-step.png new file mode 100644 index 000000000..32b578576 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/define-step.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/find-step.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/find-step.png new file mode 100644 index 000000000..54e2741c6 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/find-step.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/inputs.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/inputs.png new file mode 100644 index 000000000..77434e44d Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/inputs.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/org-ready.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/org-ready.png new file mode 100644 index 000000000..e3abd5c7f Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/org-ready.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/outputs.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/outputs.png new file mode 100644 index 000000000..3b7d326c5 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/outputs.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-existing/step-inputs.png b/docs/english/tutorial/custom-steps-workflow-builder-existing/step-inputs.png new file mode 100644 index 000000000..bf8fc7871 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-existing/step-inputs.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/app-token.png b/docs/english/tutorial/custom-steps-workflow-builder-new/app-token.png new file mode 100644 index 000000000..c500bb003 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/app-token.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/bot-token.png b/docs/english/tutorial/custom-steps-workflow-builder-new/bot-token.png new file mode 100644 index 000000000..2d624117d Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/bot-token.png differ 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 new file mode 100644 index 000000000..75be7aa32 --- /dev/null +++ b/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md @@ -0,0 +1,358 @@ +# Custom Steps for Workflow Builder (new app) + +:::info[This feature requires a paid plan] +If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +::: + +Adding a workflow step to your app and implementing a corresponding function listener is how you define a custom Workflow Builder step. In this tutorial, you'll use [Bolt for Python](/tools/bolt-python/) to add your workflow step, then wire it up in [Workflow Builder](https://slack.com/help/articles/360035692513-Guide-to-Workflow-Builder). + +When finished, you'll be ready to build scalable and innovative workflow steps for anyone using Workflow Builder in your workspace. + +## What are we building? {#what-are-we-building} + +In this tutorial, you'll be wiring up a sample app with a sample step and corresponding function listener to be used as a workflow step in Workflow Builder. Here's how it works: + +* When someone starts the workflow, Slack will notify your app that your custom step was invoked as part of a workflow. +* Your app will send a message to the requestor, along with a button to complete the step. +* When the user clicks or taps the button, Slack will let your app know, and your app will respond by changing the message. + +:::info[Skip to the code] +If you'd rather skip the tutorial and just head straight to the code, create a new app and use our [Bolt Python custom step sample](https://github.com/slack-samples/bolt-python-custom-step-template) as a template. The sample custom step provided in the template will be a good place to start exploring! +::: + +## Prerequisites {#prereqs} + +Before we begin, let's make sure you're set up for success. Ensure you have a development workspace where you have permission to install apps. We recommend setting up your own space used for exploration and testing in a [developer sandbox](https://api.slack.com/developer-program). + +## Cloning the sample project {#clone} + +For this tutorial, We'll use `boltstep` as the app name. For your app, be sure to use a unique name that will be easy for you to find: then, use that name wherever you see `boltstep` in this tutorial. The app will be named "Bolt Custom Step", as that is defined in the `manifest.json` file of the sample app code. + +Let's start by opening a terminal and cloning the starter template repository: + +```sh +git clone https://github.com/slack-samples/bolt-python-custom-step-template boltstep +``` + +Once the terminal is finished cloning the template, change directories into your newly prepared app project: + +```sh +cd boltstep +``` + +If you're using VSCode (highly recommended), you can enter `code .` from your project's directory and VSCode will open your new project. + +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 +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. + +## Creating your app from a manifest {#create-app} + +Open a browser and navigate to [your apps page](https://api.slack.com/apps). This is where we will create a new app with our previously copied manifest details. Click the **Create New App** button, then select **From an app manifest** when prompted to choose how you'd like to configure your app's settings. + +![Create app from manifest](manifest.png) + +Next, select a workspace where you have permissions to install apps, and click **Next**. Select the **JSON** tab and clear the existing contents. Paste the contents of the `manifest.json` file you previously copied. + +Click **Next** again. You will be shown a brief overview of the features your app includes. You'll see we are creating an app with a `chat:write` bot scope, an App Home and Bot User, as well as Socket Mode, Interactivity, an Event Subscription, and Org Deploy. We'll get into these details later. Click **Create**. + +### App settings {#app-settings} + +All of your app's settings can be configured within these screens. By creating an app from an existing manifest, you will notice many settings have already been configured. Navigate to **Org Level Apps** and notice that we've already opted in. This is a requirement for adding workflow steps to an app. + +Navigate to **Event Subscriptions** and expand **Subscribe to bot events** to see that we have subscribed to the `function_executed` event. This is also a requirement for adding workflow steps to our app, as it lets our app know when a step has been triggered, allowing our app to respond to it. + +Another configuration setting to note is **Socket Mode**. We have turned this on for our local development, but socket mode is not intended for use in a production environment. When you are satisfied with your app and ready to deploy it to a production environment, you should switch to using public HTTP request URLs. Read more about getting started with HTTP in [Bolt for Python here](/tools/bolt-python/getting-started). + +Clicking on **Workflow Steps** in the left nav will show you that one workflow step has been added! This reflects the `function` defined in our manifest: functions are workflow steps. We will get to this step's implementation later. + +![Workflow step](workflow-step.png) + +### Tokens {#tokens} + +In order to connect our app here with the logic of our sample code set up locally, we need to obtain two tokens, a bot token and an app token. + +* **Bot tokens** are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that most apps use. +* **App-level** tokens represent your app across organizations, including installations by all individual users on all workspaces in a given organization and are commonly used for creating websocket connections to your app. + +To generate an app token, navigate to **Basic Information** and scroll down to **App-Level Token**. + +![App token](app-token.png) + +Click **Generate Token and Scopes**, then **Add Scope** and choose `connections:write`. Choose a name for your token and click **Generate**. Copy that value, save it somewhere accessible, and click **Done** to close out of the modal. + +Next up is the bot token. We can only get this token by installing the app into the workspace. Navigate to **Install App** and click the button to install, choosing **Allow** at the next screen. + +![Install app](install.png) + +You will then have a bot token. Again, copy that value and save it somewhere accessible. + +![Bot token](bot-token.png) + +💡 Treat your tokens like passwords and keep them safe. Your app uses them to post and retrieve information from Slack workspaces. Minimally, do NOT commit them to version control. + +## Starting your local development server {#local} + +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 ``: + +```sh +SLACK_APP_TOKEN= +SLACK_BOT_TOKEN= +``` + +Now save the file and try starting your app: + +```sh +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`. + +With your development server running, continue to the next step. + +:::info[If you need to stop running the local development server, press `` + `c` to end the process.] +::: + +## Wiring up the sample step in Workflow Builder {#wfb} + +The starter project you cloned contains a sample custom step lovingly titled “Sample step". Let’s see how a custom step defined in Bolt appears in Workflow Builder. + +In the Slack Client of your development workspace, open Workflow Builder by clicking on the workspace name, **Tools**, then **Workflow Builder**. Create a new workflow, then select **Build Workflow**: + +![Creating a new workflow](wfb-1.png) + +Select **Choose an event** under **Start the workflow...**, then **From a link in Slack** to configure this workflow to start when someone clicks its shortcut link: + +![Starting a new workflow from a shortcut link](wfb-2.png) + +Click the **Continue** button to confirm that this is workflow should start with a shortcut link: + +![Confirming a new shortcut workflow setup](wfb-3.png) + +Find the sample step provided in the template by either searching for the name of your app (e.g., `Bolt Custom Step`) or the name of your step (e.g. `Sample step`) in the Steps search bar. + +If you search by app name, any custom step that your app has defined will be listed. + +Add the “Sample step" in the search results to the workflow: + +![Adding the sample step to the workflow](wfb-4.png) + +As soon as you add the “Sample step" to the workflow, a modal will appear to configure the step's input—in this case, a user variable: + +![Configuring the sample step's inputs](wfb-5.png) + +Configure the user input to be “Person who used this workflow”, then click the **Save** button: + +![Saving the sample step after configuring the user input](wfb-6.png) + +Click the **Finish Up** button, then provide a name and description for your workflow. + +Finally, click the **Publish** button: + +![Publishing a workflow](wfb-7.png) + +Copy the shortcut link, then exit Workflow Builder and paste the link to a message in any channel you’re in: + +![Copying a workflow link](wfb-8.png) + +After you send a message containing the shortcut link, the link will unfurl and you’ll see a **Start Workflow** button. + +Click the **Start Workflow** button: + +![Starting your new workflow](wfb-9.png) + +You should see a new direct message from your app: + +![A new direct message from your app](wfb-10.png) + +The message from your app asks you to click the **Complete step** button: + +![A new direct message from your app](wfb-11.png) + +Once you click the button, the direct message to you will be updated to let you know that the step interaction was successfully completed: + +![Sample step finished successfully](wfb-12.png) + +Now that we’ve gotten a feel for how we will use the custom step, let’s learn more about how function listeners work. + +## Discovering listeners {#listeners} + +Now that we’ve seen how custom steps are used in Workflow Builder, let’s understand how the function listener code works to respond to an event when the step is triggered. + +We’ll first review the step definition in the `manifest.json`, then we’ll look at the two listener functions in our app code: one to let us know when the step starts, and one to let us know when someone clicks or taps one of the buttons we sent over. + +### Defining the custom step {#define-custom-step} + +Opening the `manifest.json` file included in the sample app shows a `functions` property that includes a definition for our `sample_step`: + +```json +// manifest.json +... + "functions": { + "sample_step": { + "title": "Sample step", + "description": "Runs sample step", + "input_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "Message recipient", + "is_required": true, + "hint": "Select a user in the workspace", + "name": "user_id" + } + }, + "output_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "User that completed the step", + "is_required": true, + "name": "user_id" + } + } + } + } +``` + +From the step definition, we can see an input parameter and an output parameter defined. + +### Inputs and outputs {#inputs-outputs} + +The custom step will take the following input: Message recipient (as a Slack User ID). + +The custom step will produce the following output: The user that completed the step. + +* When the step is invoked, a message will be sent to the user who invoked the workflow with a button to complete the step. +* When the button is clicked, a message is posted indicating the step's completion. + +### Implementing the function listener {#function-listener} + +The first thing we’ll do when adding a custom workflow step to our Bolt app is register a new **function listener**. In Bolt, a function listener allows developers to execute custom code in response to specific Slack events or actions by registering a method that handles predefined requests or commands. We register a function listener via the `function` method provided by our app instance. + +1. Open your project’s `app.py` file in your code editor. +2. Between the initialization code for the app instance and the `sample_step` registration, you'll see a listener defined for our custom step: + +```py +# app.py +... +@app.function("sample_step") +def handle_sample_step_event(inputs: dict, say: Say, fail: Fail, logger: logging.Logger): + user_id = inputs["user_id"] + + try: + say( + channel=user_id, # sending a DM to this user + text="Click the button to signal the step has completed", + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Click the button to signal the step has completed"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Complete step"}, + "action_id": "sample_click", + }, + } + ], + ) + except Exception as e: + logger.exception(e) + fail(f"Failed to handle a step request (error: {e})") + + +``` + +#### Anatomy of a `.function()` listener {#function-listener-anatomy} + +The function decorator (`function()`) accepts an argument of type `str` and is the unique callback ID of the step. For our custom step, we’re using `sample_step`. Every custom step you implement in an app needs to have a unique callback ID. + +The callback function is where we define the logic that will run when Slack tells the app that a user in the Slack client started a workflow that contains the `sample_step` custom step. + +The callback function offers various utilities that can be used to take action when a step execution event is received. The ones we’ll be using here are: + +* `inputs` provides access to the workflow variables passed into the step when the workflow was started +* `fail` indicates when the step invoked for the current workflow step has an error +* `logger` provides a Python standard logger instance +* `say` calls the `chat.Postmessage` API method + +#### Understanding the function listener's callback logic {#function-listener-callback-logic} + +When our step is executed, we want a message to be sent to the invoking user. That message should include a button that prompts the user to complete the step. + +When Slack tells your Bolt app that the `sample_step` step was invoked, this step uses `chat.postMessage` to send a message to the `user_id` channel (which means this will be sent as a DM to the Slack user whose ID == `user_id`) with some text and blocks. The Block Kit element being sent as part of the message is a button, labeled 'Complete step' (which sends the `sample_click` action ID). + +Once the message is sent, your Bolt app will wait until the user has clicked the button. As soon as they click or tap the button, Slack will send back the action ID associated with the button to your Bolt app. + +In order for your Bolt app to listen for these actions, we’ll now define an action listener. + +### Implementing the action listener {#action-listener} + +The message we send to the user will include the button prompting them to complete the step. + +To listen for and respond to this button click, you'll see an `.action()` listener to `app.py`, right after the function listener definition: + +```py +# app.py +... +@app.action("sample_click") +def handle_sample_click( + ack: Ack, body: dict, context: BoltContext, client: WebClient, complete: Complete, fail: Fail, logger: logging.Logger +): + ack() + + try: + # Since the button no longer works, we should remove it + client.chat_update( + channel=context.channel_id, + ts=body["message"]["ts"], + text="Congrats! You clicked the button", + ) + + # Signal that the step completed successfully + complete({"user_id": context.actor_user_id}) + except Exception as e: + logger.exception(e) + fail(f"Failed to handle a step request (error: {e})") + +``` + +#### Anatomy of an `.action()` listener {#action-listener-anatomy} + +Similar to a function listener, the action listener registration method (`.action()`) takes two arguments: + +* The first argument is the unique callback ID of the action that your app will respond to. +* The second argument is an asynchronous callback function, where we define the logic that will run when Slack tells our app that the user has clicked or tapped the button. + +Just like the function listener’s callback function, the action listener’s callback function offers various utilities that can be used to take action when an action event is received. The ones we’ll be using here are: + +* `client`, which provides access to Slack API methods +* `action`, which provides the action’s event payload +* `complete`, which is a utility method indicating to Slack that the step behind the workflow step that was just invoked has completed successfully +* `fail`, which is a utility method for indicating that the step invoked for the current workflow step had an error + +#### Understanding the action listener's callback logic {#action-listener-callback-logic} + +Recall that we sent over a message with the button back in the function listener. + +When the button is pressed, we want to complete the step, update the message, and define `outputs` that can be used for subsequent steps in Workflow Builder. + +Slack will send an action event payload to your app when the button is clicked or tapped. In the action listener, we extract all the information we can use, and if all goes well, let Slack know the step was successful by invoking `complete`. We also handle cases where something goes wrong and produces an error. + +## Next steps {#next-steps} + +That's it — we hope you learned a lot! + +In this tutorial, we added custom steps via the manifest, but if you'd like to see how to add custom steps in the [app settings](https://api.slack.com/apps) to an existing app, follow along with the [Create a custom step for Workflow Builder: existing Bolt app](/tools/bolt-python/tutorial/custom-steps-workflow-builder-existing) tutorial. + +If you're interested in exploring how to create custom steps to use in Workflow Builder as steps with our Deno Slack SDK, too, that tutorial can be found [here](/tools/deno-slack-sdk/tutorials/workflow-builder-custom-step/). diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/install.png b/docs/english/tutorial/custom-steps-workflow-builder-new/install.png new file mode 100644 index 000000000..bbfc83c13 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/install.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/manifest.png b/docs/english/tutorial/custom-steps-workflow-builder-new/manifest.png new file mode 100644 index 000000000..013c0f7be Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/manifest.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-1.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-1.png new file mode 100644 index 000000000..566a11224 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-1.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-10.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-10.png new file mode 100644 index 000000000..859c4bf1a Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-10.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-11.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-11.png new file mode 100644 index 000000000..be2159a59 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-11.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-12.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-12.png new file mode 100644 index 000000000..0e59ed5a5 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-12.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-2.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-2.png new file mode 100644 index 000000000..8744bee39 Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-2.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-3.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-3.png new file mode 100644 index 000000000..17601ffda Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-3.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-4.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-4.png new file mode 100644 index 000000000..79f06ac7b Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-4.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-5.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-5.png new file mode 100644 index 000000000..3a304316c Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-5.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-6.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-6.png new file mode 100644 index 000000000..b5e85e95e Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-6.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-7.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-7.png new file mode 100644 index 000000000..a8992b84e Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-7.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-8.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-8.png new file mode 100644 index 000000000..1a3d636cb Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-8.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-9.png b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-9.png new file mode 100644 index 000000000..1347c463f Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/wfb-9.png differ diff --git a/docs/english/tutorial/custom-steps-workflow-builder-new/workflow-step.png b/docs/english/tutorial/custom-steps-workflow-builder-new/workflow-step.png new file mode 100644 index 000000000..3ae8c26fa Binary files /dev/null and b/docs/english/tutorial/custom-steps-workflow-builder-new/workflow-step.png differ diff --git a/docs/english/tutorial/custom-steps.md b/docs/english/tutorial/custom-steps.md new file mode 100644 index 000000000..50bd723ce --- /dev/null +++ b/docs/english/tutorial/custom-steps.md @@ -0,0 +1,272 @@ +--- +title: Custom Steps +--- + +:::info[This feature requires a paid plan] +If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +::: + + +With custom steps for Bolt apps, your app can create and process workflow steps that users later add in Workflow Builder. This guide goes through how to build a custom step for your app using the [app settings](https://api.slack.com/apps). + +If you're looking to build a custom step using the Deno Slack SDK, check out our guide on [creating a custom step for Workflow Builder with the Deno Slack SDK](/tools/deno-slack-sdk/tutorials/workflow-builder-custom-step/). + +You can also take a look at the template for the [Bolt for Python custom workflow step](https://github.com/slack-samples/bolt-python-custom-step-template) on GitHub. + +There are two components of a custom step: the step definition in the app manifest, and a listener to handle the `function_executed` event in your project code. + +## Opt in to org-ready apps {#org-ready-apps} + +Before we create the step definition, we first need to opt in to organization-ready apps. The app must opt-in to org-ready apps to be able to add the custom step to its manifest. This can be done in one of two ways: + +- Set the manifest `settings.org_deploy_enabled` property to `true`. +- Alternatively, navigate to your [apps](https://api.slack.com/apps), select your app, then under the **Features** section in the navigation, select **Org Level Apps** and then **Opt-In**. + +Whichever method you use, the following will be reflected in the app manifest as such: + +```json + "settings": { + "org_deploy_enabled": true, + ... + } +``` + +Next, the app must be installed at the organization level. While it is possible to install the app at a workspace level, doing so means that the custom steps will not appear in Workflow Builder. To remedy this, install the app at the organization level. + +If you are a developer who is not an admin of their organization, you will need to request an Org Admin to perform this installation at the organization level. To do this: + +- Navigate to your [apps](https://api.slack.com/apps) page and select the app you'd like to install. +- Under **Settings**, select **Collaborators**. +- Add an Org Admin as a collaborator. + +The Org Admin can then install your app directly at the org level from the [app settings](https://api.slack.com/apps) page. + +## Defining the custom step {#define-step} + +A workflow step's definition contains information about the step, including its `input_parameters`, `output_parameters`, as well as display information. + +Each step is defined in the `functions` object of the manifest. Each entry in the `functions` object is a key-value pair representing each step. The key is the step's `callback_id`, which is any string you wish to use to identify the step (max 100 characters), and the value contains the details listed in the table below for each separate custom step. We recommend using the step's name, like `sample_step` in the code example below for the step's `callback_id`. + +Field | Type | Description | Required? +---- | ----- | ------------|---------- +`title` | String | A string to identify the step. Max 255 characters. | Yes +`description` | String | A succinct summary of what your step does. | No +`input_parameters` | Object | An object which describes one or more [input parameters](#inputs-outputs) that will be available to your step. Each top-level property of this object defines the name of one input parameter available to your step.| No +`output_parameters` | Object | An object which describes one or more [output parameters](#inputs-outputs) that will be returned by your step. Each top-level property of this object defines the name of one output parameter your step makes available. | No + +Once you are in your [app settings](https://api.slack.com/apps), navigate to **Workflow Steps** in the left nav. Click **Add Step** and fill out your step details, including callback ID, name, description, input parameters, and output parameters. + +### Defining input and output parameters {#inputs-outputs} + +Step inputs and outputs (`input_parameters` and `output_parameters`) define what information goes into a step before it runs and what comes out of a step after it completes, respectively. + +Both inputs and outputs adhere to the same schema and consist of a unique identifier and an object that describes the input or output. + +Each input or output that belongs to `input_parameters` or `output_parameters` must have a unique key. + +Field | Type | Description +------|------|------------- +`type` | String | Defines the data type and can fall into one of two categories: primitives or Slack-specific. +`title` | String | The label that appears in Workflow Builder when a user sets up this step in their workflow. +`description` | String | The description that accompanies the input when a user sets up this step in their workflow. +`dynamic_options` | Object | For custom steps dynamic options in Workflow Builder, define this property and point to a custom step designed to return the set of dynamic elements once the step is added to a workflow within Workflow Builder. Dynamic options in Workflow Builder can be rendered in one of two ways: as a drop-down menu (single-select or multi-select), or as a set of fields. Refer to custom steps dynamic options for Workflow Builder using [Bolt for JavaScript](/tools/bolt-js/concepts/custom-steps-dynamic-options/) or [Bolt for Python](https://docs.slack.dev/tools/bolt-python/concepts/custom-steps-dynamic-options/) for more details. +`is_required` | Boolean | Indicates whether or not the input is required by the step in order to run. If it’s required and not provided, the user will not be able to save the configuration nor use the step in their workflow. This property is available only in v1 of the manifest. We recommend v2, using the `required` array as noted in the example above. +`hint` | String | Helper text that appears below the input when a user sets up this step in their workflow. + +In addition, the `dynamic_options` field has two required properties: + +Property | Type | Description +------|------|------------- +`function` | String | A reference to the custom step that should be used as a dynamic option. +`inputs` | Object | Maps the inputs from the custom step consuming the dynamic option to the inputs required by the step used as a dynamic option. + +For example: + +``` +"inputs": { + "category": { + "value": "{{input_parameters.category}}" + } +} +``` + +Once you've added your step details, save your changes, then navigate to **App Manifest**. Notice your new step configuration reflected in the `function` property! + +#### Sample manifest {#sample-manifest} + +Here is a sample app manifest laying out a step definition. This definition tells Slack that the step in our workspace with the callback ID of `sample_step` belongs to our app, and that when it runs, we want to receive information about its execution event. + +```json +"functions": { + "sample_step": { + "title": "Sample step", + "description": "Runs sample step", + "input_parameters": { + "properties": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "Message recipient", + "hint": "Select a user in the workspace", + "name": "user_id" + } + }, + "required": [ + "user_id" + ] + }, + "output_parameters": { + "properties": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "User that received the message", + "name": "user_id" + } + }, + "required": [ + "user_id" + ] + }, + } +} +``` + +### Adding steps for existing apps {#existing-apps} + +If you are adding custom steps to an existing app directly to the app manifest, you will also need to add the `function_runtime` property to the app manifest. Do this in the `settings` section as such: + +```json +"settings": { + ... + "function_runtime": "remote" +} +``` + +If you are adding custom steps in the **Workflow Steps** section of the [App Config](https://api.slack.com/apps) as shown above, then this will be added automatically. + +## Listening to function executions {#listener} + +When your custom step is executed in a workflow, your app will receive a `function_executed` event. The callback provided to the `function()` method will be run when this event is received. + +The callback is where you can access `inputs`, make third-party API calls, save information to a database, update the user’s Home tab, or set the output values that will be available to subsequent workflow steps by mapping values to the `outputs` object. + +Your app must call `complete()` to indicate that the step’s execution was successful, or `fail()` to signal that the step failed to complete. + +Notice in the example code here that the name of the step, `sample_step`, is the same as it is listed in the manifest above. This is required. + +```py +@app.function("sample_step") +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( + channel=user_id, + text=f"Greetings <@{user_id}>!" + ) + complete({"user_id": user_id}) + except Exception as e: + logger.exception(e) + fail(f"Failed to complete the step: {e}") + +``` + +Here's another example. Note in this snippet, the name of the step, `create_issue`, must be listed the same as it is listed in the manifest file. + +```py +@app.function("create_issue") +def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger): + ack() + JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") + + headers = { + "Authorization": f'Bearer {os.getenv("JIRA_SERVICE_TOKEN")}', + "Accept": "application/json", + "Content-Type": "application/json", + } + + try: + project: str = inputs["project"] + issue_type: str = inputs["issuetype"] + + url = f"{JIRA_BASE_URL}/rest/api/latest/issue" + + payload = json.dumps( + { + "fields": { + "description": inputs["description"], + "issuetype": {"id" if issue_type.isdigit() else "name": issue_type}, + "project": {"id" if project.isdigit() else "key": project}, + "summary": inputs["summary"], + }, + } + ) + + response = requests.post(url, data=payload, headers=headers) + + response.raise_for_status() + json_data = json.loads(response.text) + complete(outputs={ + "issue_id": json_data["id"], + "issue_key": json_data["key"], + "issue_url": f'https://{JIRA_BASE_URL}/browse/{json_data["key"]}' + }) + except Exception as e: + logger.exception(e) + fail(f"Failed to handle a step request (error: {e})") + +``` + +### Anatomy of a function listener {#anatomy} + +The first argument (in our case above, `sample_step`) is the unique callback ID of the step. After receiving an event from Slack, this identifier is how your app knows which custom step handler to invoke. This `callback_id` also corresponds to the step definition provided in your manifest file. + +The second argument is the callback function, or the logic that will run when your app receives notice from Slack that `sample_step` was run by a user—in the Slack client—as part of a workflow. + +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` 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. + +## Responding to interactivity {#interactivity} + +Interactive elements provided to the user from within the `function()` method’s callback are associated with that unique `function_executed` event. This association allows for the completion of steps at a later time, like once the user has clicked a button. + +Incoming actions that are associated with a step have the same `inputs`, `complete`, and `fail` utilities as offered by the `function()` method. + +```py +# If associated with a step, step-specific utilities are made available +@app.action("sample_click") +def handle_sample_click(context: BoltContext, complete: Complete, fail: Fail, logger: logging.Logger): + try: + # Signal the step has completed once the button is clicked + complete({"user_id": context.actor_user_id}) + except Exception as e: + logger.exception(e) + fail(f"Failed to handle a step request (error: {e})") + +``` + +## Deploying a custom step {#deploy} + +When you're ready to deploy your steps for wider use, you'll need to decide *where* to deploy, since Bolt apps are not hosted on the Slack infrastructure. + +### Control step access {#access} + +You can choose who has access to your custom steps. To define this, refer to the [custom function access](/tools/deno-slack-sdk/guides/controlling-access-to-custom-functions) page. + +### Distribution {#distribution} + +Distribution works differently for Slack apps that contain custom steps when the app is within a standalone (non-Enterprise Grid) workspace versus within an Enterprise Grid organization. + +* **Within a standalone workspace**: Slack apps that contain custom steps can be installed on the same workspace and used within that workspace. We do not support distribution to other standalone workspaces (also known as public distribution). +* **Within an organization**: Slack apps that contain custom steps should be org-ready (enabled for private distribution) and installed on the organization level. They must also be granted access to at least one workspace in the organization for the steps to appear in Workflow Builder. + +Apps containing custom steps cannot be distributed publicly or submitted to the Slack Marketplace. We recommend sharing your code as a public repository in order to share custom steps in Bolt apps. + +## Related tutorials {#tutorials} + +* [Custom steps for Workflow Builder (new app)](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) +* [Custom steps for Workflow Builder (existing app)](/tools/bolt-python/tutorial/custom-steps-workflow-builder-existing/) \ No newline at end of file diff --git a/docs/english/tutorial/modals/base_link.gif b/docs/english/tutorial/modals/base_link.gif new file mode 100644 index 000000000..263799dda Binary files /dev/null and b/docs/english/tutorial/modals/base_link.gif differ diff --git a/docs/english/tutorial/modals/final_product.gif b/docs/english/tutorial/modals/final_product.gif new file mode 100644 index 000000000..0789badb6 Binary files /dev/null and b/docs/english/tutorial/modals/final_product.gif differ diff --git a/docs/english/tutorial/modals/heart_icon.gif b/docs/english/tutorial/modals/heart_icon.gif new file mode 100644 index 000000000..1dc0860df Binary files /dev/null and b/docs/english/tutorial/modals/heart_icon.gif differ diff --git a/docs/english/tutorial/modals/interactivity_url.png b/docs/english/tutorial/modals/interactivity_url.png new file mode 100644 index 000000000..af877a8e2 Binary files /dev/null and b/docs/english/tutorial/modals/interactivity_url.png differ diff --git a/docs/english/tutorial/modals/modals.md b/docs/english/tutorial/modals/modals.md new file mode 100644 index 000000000..d25470b97 --- /dev/null +++ b/docs/english/tutorial/modals/modals.md @@ -0,0 +1,134 @@ +# Modals + +If you're learning about Slack apps, modals, or slash commands for the first time, you've come to the right place! In this tutorial, we'll take a look at setting up your very own server using GitHub Codespaces, then using that server to run your Slack app built with the [**Bolt for Python framework**](https://github.com/SlackAPI/bolt-python). + +:::info[GitHub Codespaces] +GitHub Codespaces is an online IDE that allows you to work on code and host your own server at the same time. While Codespaces is good for testing and development purposes, it should not be used in production. + +::: + +At the end of this tutorial, your final app will look like this: + +![announce](/img/bolt-python/announce.gif) + +And will make use of these Slack concepts: +* [**Block Kit**](/block-kit/) is a UI framework for Slack apps that allows you to create beautiful, interactive messages within Slack. If you've ever seen a message in Slack with buttons or a select menu, that's Block Kit. +* [**Modals**](/surfaces/modals) are a pop-up window that displays right in Slack. They grab the attention of the user, and are normally used to prompt users to provide some kind of information or input in a form. +* [**Slash Commands**](/interactivity/implementing-slash-commands) allow you to invoke your app within Slack by just typing into the message composer box. e.g. `/remind`, `/topic`. + +If you're familiar with using Heroku you can also deploy directly to Heroku with the following button. + +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://www.heroku.com/deploy?template=https://github.com/wongjas/modal-example) + +--- + +## Setting up your app within App Settings {#setting-up-app-settings} + +You'll need to create an app and configure it properly within App Settings before using it. + +1. [Create a new app](https://api.slack.com/apps/new), click `From a Manifest`, and choose the workspace that you want to develop on. Then copy the following JSON object; it describes the metadata about your app, like its name, its bot display name and permissions it will request. + +```json +{ + "display_information": { + "name": "Intro to Modals" + }, + "features": { + "bot_user": { + "display_name": "Intro to Modals", + "always_online": false + }, + "slash_commands": [ + { + "command": "/announce", + "description": "Makes an announcement", + "should_escape": false + } + ] + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write", + "commands" + ] + } + }, + "settings": { + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } +} +``` + +2. Once your app has been created, scroll down to `App-Level Tokens` and create a token that requests for the [`connections:write`](/reference/scopes/connections.write) scope, which allows you to use [Socket Mode](/apis/events-api/using-socket-mode), a secure way to develop on Slack through the use of WebSockets. Copy the value of your app token and keep it for safe-keeping. + +3. Install your app by heading to `Install App` in the left sidebar. Hit `Allow`, which means you're agreeing to install your app with the permissions that it is requesting. Be sure to copy the token that you receive, and keep it somewhere secret and safe. + +## Starting your Codespaces server {#starting-server} + +1. Log into GitHub and head to this [repository](https://github.com/wongjas/modal-example). + +2. Click the green `Code` button and hit the `Codespaces` tab and then `Create codespace on main`. This will bring up a code editor within your browser so you can start coding. + +## Understanding the project files {#understanding-files} + +Within the project you'll find a `manifest.json` file. This is a a configuration file used by Slack apps. With a manifest, you can create an app with a pre-defined configuration, or adjust the configuration of an existing app. + +The `simple_modal_example.py` Python script contains the code that powers your app. If you're going to tinker with the app itself, take a look at the comments found within the `simple_modal_example.py` file! + +The `requirements.txt` file contains the Python package dependencies needed to run this app. + +:::info[This repo contains optional Heroku-specific configurations] + +The `app.json` file defines your Heroku app configuration including environment variables and deployment settings, to allow your app to deploy with one click. `Procfile` is a Heroku-specific file that tells Heroku what command to run when starting your app — in this case a Python script would run as a `worker` process. If you aren't deploying to Heroku, you can ignore both these files. + +::: + +## Adding tokens {#adding-tokens} + +1. Open a terminal up within the browser's editor. + +2. Grab the app and bot tokens that you kept safe. We're going to set them as environment variables. + +```bash +export SLACK_APP_TOKEN= +export SLACK_BOT_TOKEN= +``` + +## Running the app {#running-app} + +1. Activate a virtual environment for your Python packages to be installed. + +```bash +# Setup your python virtual environment +python3 -m venv .venv +source .venv/bin/activate +``` + +2. Install the dependencies from the `requirements.txt` file. + + +```bash +# Install the dependencies +pip install -r requirements.txt +``` + +3. Start your app using the `python3 simple_modal_example.py` command. + +```bash +# Start your local server +python3 simple_modal_example.py +``` + +4. Now that your app is running, you should be able to see it within Slack. Test this by heading to Slack and typing `/announce`. + +All done! 🎉 You've created your first slash command using Block Kit and modals! The world is your oyster; play around with [Block Kit Builder](https://app.slack.com/block-kit-builder) and create more complex modals and place them in your code to see what happens! + +## Next steps {#next-steps} + +If you want to learn more about Bolt for Python, refer to the [Getting Started guide](https://docs.slack.dev/tools/bolt-python/getting-started). \ No newline at end of file diff --git a/docs/english/tutorial/modals/slash_command.png b/docs/english/tutorial/modals/slash_command.png new file mode 100644 index 000000000..2473bda3b Binary files /dev/null and b/docs/english/tutorial/modals/slash_command.png differ diff --git a/docs/english/tutorial/order-confirmation/order-confirmation.md b/docs/english/tutorial/order-confirmation/order-confirmation.md new file mode 100644 index 000000000..695d6965a --- /dev/null +++ b/docs/english/tutorial/order-confirmation/order-confirmation.md @@ -0,0 +1,553 @@ +--- +title: Create a Salesforce order confirmation app +--- + +In this tutorial, you'll use the [Bolt for Python](/tools/bolt-python/) framework and [Block Kit Builder](https://app.slack.com/block-kit-builder) to create an order confirmation app that links to a system of record, like Salesforce. + +The Slack app will: +* allow users to enter order numbers from within Slack, along with some additional order information, +* post that information to a Slack channel, and +* send the information to the system of record. + +End users will be able to enter information across devices, as many will likely be using a mobile device. + +Along the way, you'll learn how to use the Bolt for Python starter app template as a jumping off point for your own custom apps. Let's begin! + +:::warning[Consider the following] + +This tutorial was created for educational purposes within a Slack workshop. As a result, it has not been tested quite as rigorously as our sample apps. Proceed carefully if you'd like to use a similar app in production. + +::: + +## Getting started + +### Installing the Slack CLI + +If you don't already have the Slack CLI, install it from your terminal: navigate to the installation guide ([for Mac and Linux](/tools/slack-cli/guides/installing-the-slack-cli-for-mac-and-linux) or [for Windows](/tools/slack-cli/guides/installing-the-slack-cli-for-windows)) and follow the steps. + +### Cloning the starter app + +Once installed, use the command `slack create` to get started with the Bolt for Python [starter template](https://github.com/slack-samples/bolt-python-starter-template). Alternatively, you can clone the template using Git. + +You can remove the portions from the template that are not used within this tutorial to make things a bit cleaner for yourself. To do this, open your project in VS Code (you can do this from the terminal with the `code .` command) and delete the `commands`, `events`, and `shortcuts` folders from the `/listeners` folder. You can also do the same to the corresponding folders within the `/listeners/tests` folder as well. Finally, remove the imports of these files from the `/listeners/__init__.py` file. + +## Creating your app + +We’ll use the contents of the `manifest.json` file below. This file describes the metadata associated with your app, like its name and permissions that it requests. + +These values are used to create an app in one of two ways: + +- **With the Slack CLI**: Save the contents of the file to your project's `manifest.json` file then skip ahead to [starting your app](#starting-your-app). +- **With app settings**: Copy the contents of the file and [create a new app](https://api.slack.com/apps/new). Next, choose **From a manifest** and follow the prompts, pasting the manifest file contents you copied. + +```json +{ + "_metadata": { + "major_version": 1, + "minor_version": 1 + }, + "display_information": { + "name": "Delivery Tracker App" + }, + "features": { + "bot_user": { + "display_name": "Delivery Tracker App", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "channels:history", + "chat:write" + ] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "message.channels" + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } +} +``` + +### Tokens + +Once your app has been created, scroll down to **App-Level Tokens** on the **Basic Information** page and create a token that requests the [`connections:write`](/reference/scopes/connections.write) scope. This token will allow you to use [Socket Mode](/apis/events-api/using-socket-mode), which is a secure way to develop on Slack through the use of WebSockets. Save the value of your app token and store it in a safe place (we’ll use it in the next step). + +### Install app + +Still in the app settings, navigate to the **Install App** page in the left sidebar. Install your app. When you press **Allow**, this means you’re agreeing to install your app with the permissions that it’s requesting. Copy the bot token that you receive as well and store this in a safe place as well for subsequent steps. + +## Saving credentials + +Within a terminal of your choice, set the two tokens from the previous step as environment variables using the commands below. Make sure not to mix these two up, `SLACK_APP_TOKEN` will start with “xapp-“ and `SLACK_BOT_TOKEN` will start with “xoxb-“. + +For macOS: + +```bash +export SLACK_APP_TOKEN= +export SLACK_BOT_TOKEN= +``` + +For Windows Command Prompt: + +```cmd +set SLACK_APP_TOKEN= +set SLACK_BOT_TOKEN= +``` + +For Windows PowerShell: + +```powershell +$env:SLACK_APP_TOKEN="YOUR-APP-TOKEN-HERE" +$env:SLACK_BOT_TOKEN="YOUR-BOT-TOKEN-HERE" +``` + +## Starting your app {#starting-your-app} + +Run the following commands to activate a virtual environment for your Python packages to be installed, install the dependencies, and start your app. + +```bash +# Setup your python virtual environment +python -m venv .venv +source .venv/bin/activate + +# Install the dependencies +pip install -r requirements.txt + +# Start your local server +slack run +``` + +If you're not using the Slack CLI, a different `python` command can be used to start your app instead: + +```sh +python app.py +``` + +Now that your app is running, you should be able to see it within Slack. In Slack, create a channel that you can test in and try inviting your bot to it using the `/invite @Your-app-name-here` command. Check that your app works by saying “hi” in the channel where your app is, and you should receive a message back from it. If you don’t, ensure you completed all the steps above. + +## Coding the app + +We'll make four changes to the app: + +* Update the “hi” message to something more interesting and interactive +* Handle when the wrong delivery ID button is pressed +* Handle when the correct delivery IDs are sent and bring up a modal for more information +* Send the information to all of the places needed when the form is submitted (including third-party locations) + +For all of these steps, we will use [Block Kit Builder](https://app.slack.com/block-kit-builder), a tool that helps you create messages, modals and other surfaces within Slack. Open [Block Kit Builder](https://app.slack.com/block-kit-builder), take a look, and play around! We’ll create some views next. + +### Updating the "hi" message + +The first thing we want to do is change the “hi, how are you?” message from our app into something more useful. Here’s a `blocks` object built with Block Kit Builder: + +```json + + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Confirm *{delivery_id}* is correct?" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Correct", + "emoji": true + }, + "style": "primary", + "action_id": "approve_delivery" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Not correct", + "emoji": true + }, + "style": "danger", + "action_id": "deny_delivery" + } + ] + } + ] + +``` + +Take the function below and place your blocks within the blocks dictionary `[]`. + +```python +def delivery_message_callback(context: BoltContext, say: Say, logger: Logger): + try: + delivery_id = context["matches"][0] + say( + blocks=[] # insert your blocks here + ) + except Exception as e: + logger.error(e) +``` + +Update the payload: +* Remove the initial blocks key and convert any boolean true values to `True` to fit with Python conventions. +* If you see variables within `{}` brackets, this is part of an f-string, which allows you to insert variables within strings in a clean manner. Place the `f` character before these strings like this: + +```python +{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Confirm *{delivery_id}* is correct?", # place the "f" character here at the beginning of the string + }, +}, +``` + +Place all of this in the `sample_message.py` file. + +Next, you’ll need to register this listener to respond when a message is sent in the channel with your app. Head to `messages/__init__.py` and overwrite the function there with the one below, which registers the function. Don’t forget to add the import to the callback function as well! + +```python +from .sample_message import delivery_message_callback # import the function to this file + +def register(app: App): + # This regex will capture any number letters followed by dash + # and then any number of digits, our "confirmation number" e.g. ASDF-1234 + app.message(re.compile(r"[A-Za-z]+-\d+"))(delivery_message_callback) ## add this line! +``` + +Now, restart your server to bring in the new code and test that your function works by sending an order confirmation ID, like `HWOA-1524`, in your testing channel. Your app should respond with the message you created within Block Kit Builder. + +### Handling an incorrect delivery ID + +Notice that if you try to click on either of the buttons within your message, nothing will happen. This is because we have yet to create a function to handle the button click. Let’s start with the `Not correct` button first. + +1. Head to Block Kit Builder once again. We want to build a message that lets the user know that the wrong order ID has been submitted. Here's a [section](/reference/block-kit/blocks/section-block) block to get you started: + +```json + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Delivery *{delivery_id}* was incorrect ❌" + } + } + ] +``` + +View this block in Block Kit Builder [here](https://app.slack.com/block-kit-builder/#%7B%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22Delivery%20*%7Bdelivery_id%7D*%20was%20incorrect%20%E2%9D%8C%22%7D%7D%5D%7D). + +2. Once you have something that you like, add it to the function below and place the function within the `actions/sample_action.py` file. Remember to make any strings with variables into f-strings! + +```python +def deny_delivery_callback(ack, body, client, logger: Logger): + try: + ack() + delivery_id = body["message"]["text"].split("*")[1] + + # Calls the chat.update function to replace the message, + # preventing it from being pressed more than once. + client.chat_update( + channel=body["container"]["channel_id"], + ts=body["container"]["message_ts"], + blocks=[], # Add your blocks here! + ) + + logger.info(f"Delivery denied by user {body['user']['id']}") + except Exception as e: + logger.error(e) +``` + +This function will call the [`chat.update`](/reference/methods/chat.update) Web API method, which will update the original message with buttons, to the one that we created previously. This will also prevent the message from being pressed more than once. + +3. Make the connection to this function again within the `actions/__init__.py` folder with the following code: + +```python +from slack_bolt import App +from .sample_action import sample_action_callback # This can be deleted +from .sample_action import deny_delivery_callback + +def register(app: App): + app.action("sample_action_id")(sample_action_callback) # This can be deleted + app.action("deny_delivery")(deny_delivery_callback) # Add this line +``` + +Test out your app by sending in a confirmation number into your channel and clicking the `Not correct` button. If the message is updated, then you’re good to go onto the next step. + +### Handling a correct delivery ID + +The next step is to handle the `Confirm` button. In this case, we’re going to pull up a modal instead of just a message. + +1. Using the following modal as a base; create a modal that captures the kind of information that you need. + +```json +{ + "title": { + "type": "plain_text", + "text": "Approve Delivery" + }, + "submit": { + "type": "plain_text", + "text": "Approve" + }, + "type": "modal", + "callback_id": "approve_delivery_view", + "private_metadata": "{delivery_id}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Approving delivery *{delivery_id}*" + } + }, + { + "type": "input", + "block_id": "notes", + "label": { + "type": "plain_text", + "text": "Additional delivery notes" + }, + "element": { + "type": "plain_text_input", + "action_id": "notes_input", + "multiline": true, + "placeholder": { + "type": "plain_text", + "text": "Add notes..." + } + }, + "optional": true + }, + { + "type": "input", + "block_id": "location", + "label": { + "type": "plain_text", + "text": "Delivery Location" + }, + "element": { + "type": "plain_text_input", + "action_id": "location_input", + "placeholder": { + "type": "plain_text", + "text": "Enter the location details..." + } + }, + "optional": true + }, + { + "type": "input", + "block_id": "channel", + "label": { + "type": "plain_text", + "text": "Notification Channel" + }, + "element": { + "type": "channels_select", + "action_id": "channel_select", + "placeholder": { + "type": "plain_text", + "text": "Select channel for notifications" + } + }, + "optional": false + } + ] +} +``` + +View this modal in Block Kit Builder [here](https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22approve_delivery_view%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Approve%20Delivery%22%7D,%22private_metadata%22:%22%7Bdelivery_id%7D%22,%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22Approving%20delivery%20*%7Bdelivery_id%7D*%22%7D%7D,%7B%22type%22:%22input%22,%22block_id%22:%22notes%22,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Additional%20delivery%20notes%22%7D,%22element%22:%7B%22type%22:%22plain_text_input%22,%22action_id%22:%22notes_input%22,%22multiline%22:true,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Add%20notes...%22%7D%7D,%22optional%22:true%7D,%7B%22type%22:%22input%22,%22block_id%22:%22location%22,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Delivery%20Location%22%7D,%22element%22:%7B%22type%22:%22plain_text_input%22,%22action_id%22:%22location_input%22,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Enter%20the%20location%20details...%22%7D%7D,%22optional%22:true%7D,%7B%22type%22:%22input%22,%22block_id%22:%22channel%22,%22label%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Notification%20Channel%22%7D,%22element%22:%7B%22type%22:%22channels_select%22,%22action_id%22:%22channel_select%22,%22placeholder%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Select%20channel%20for%20notifications%22%7D%7D,%22optional%22:false%7D%5D,%22submit%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Approve%22%7D%7D). + +2. Within the `actions/sample_action.py` file, add the following function, replacing the view with the one you created above. Again, any strings with variables will be updated to f-strings and also any booleans will need to be capitalized. + +```python +def approve_delivery_callback(ack, body, client, logger: Logger): + try: + ack() + + delivery_id = body["message"]["text"].split("*")[1] + # Updates the original message so you can't press it twice + client.chat_update( + channel=body["container"]["channel_id"], + ts=body["container"]["message_ts"], + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Processed delivery *{delivery_id}*...", + }, + } + ], + ) + + # Open a modal to gather information from the user + client.views_open( + trigger_id=body["trigger_id"], + view={} # Add your view here + ) + + logger.info(f"Approval modal opened by user {body['user']['id']}") + except Exception as e: + logger.error(e) +``` + +Similar to the `deny` button, we need to hook up all the connections. Your `actions/__init__.py` should look something like this: + +```python +from slack_bolt import App +from .sample_action import deny_delivery_callback +from .sample_action import approve_delivery_callback + + +def register(app: App): + app.action("approve_delivery")(approve_delivery_callback) + app.action("deny_delivery")(deny_delivery_callback) +``` + +Test your app by typing in a confirmation number in channel, click the confirm button and see if the modal comes up and you are able to capture information from the user. + +### Submitting the form + +Lastly, we’ll handle the submission of the form, which will trigger two things. We want to send the information into the specified channel, which will let the user know that the form was successful, as well as send the information into our system of record, Salesforce. + +1. Here’s a simple example of a message that you can use to present the information in channel. + +```json + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "✅ Delivery *{delivery_id}* approved:" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Delivery Notes:*\n{notes or 'None'}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Delivery Location:*\n{loc or 'None'}" + } + } + ] +``` + +View this in Block Kit Builder [here](https://app.slack.com/block-kit-builder/?1#%7B%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22%E2%9C%85%20Delivery%20*%7Bdelivery_id%7D*%20approved:%22%7D%7D,%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*Delivery%20Notes:*%5Cn%7Bnotes%20or%20'None'%7D%22%7D%7D,%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*Delivery%20Location:*%5Cn%7Bloc%20or%20'None'%7D%22%7D%7D%5D%7D). Modify it however you like and then place it within the code below in the `/views/sample_views.py` file. + +```python +def handle_approve_delivery_view(ack, client, view, logger: Logger): + try: + ack() + + delivery_id = view["private_metadata"] + values = view["state"]["values"] + notes = values["notes"]["notes_input"]["value"] + loc = values["location"]["location_input"]["value"] + channel = values["channel"]["channel_select"]["selected_channel"] + + client.chat_postMessage( + channel=channel, + blocks=[], ## Add your message here + ) + + except Exception as e: + logger.error(f"Error in approve_delivery_view: {e}") +``` + +2. Making the connections in the `/views/__init__.py `file, we can test that this works by sending a message once again in our test channel. + +```python +from slack_bolt import App +from .sample_view import handle_approve_delivery_view + +def register(app: App): + app.view("sample_view_id")(sample_view_callback) # This can be deleted + app.view("approve_delivery_view")(handle_approve_delivery_view) ## Add this line +``` + +3. Let’s also send the information to Salesforce. There are [several ways](https://github.com/simple-salesforce/simple-salesforce?tab=readme-ov-file#examples) for you to access Salesforce through its API, but in this example, we’ve utilized `username`, `password` and `token` parameters. If you need help with getting your API token for Salesforce, take a look at [this article](https://help.salesforce.com/s/articleView?id=xcloud.user_security_token.htm&type=5). You’ll need to add these values as environment variables like we did earlier with our Slack tokens. You can use the following commands: + +```bash +export SF_USERNAME= +export SF_PASSWORD= +export SF_TOKEN= +``` + +4. We’re going to use assume that order information is stored in the Order object and that the confirmation IDs map to the 8-digit Order numbers within Salesforce. Given that assumption, we need to make a query to find the correct object, add the inputted information, and we’re done. Place this functionality before the last excerpt within the `/views/sample_views.py` file. + +```python +# Extract just the numeric portion from delivery_id + delivery_number = "".join(filter(str.isdigit, delivery_id)) + + # Update Salesforce order object + try: + sf = Salesforce( + username=os.environ.get("SF_USERNAME"), + password=os.environ.get("SF_PASSWORD"), + security_token=os.environ.get("SF_TOKEN"), + ) + + # Assuming delivery_id maps to Salesforce Order number + order = sf.query(f"SELECT Id FROM Order WHERE OrderNumber = '{delivery_number}'") # noqa: E501 + if order["records"]: + order_id = order["records"][0]["Id"] + sf.Order.update( + order_id, + { + "Status": "Delivered", + "Description": notes, + "Shipping_Location__c": loc, + }, + ) + logger.info(f"Updated order {delivery_id}") + else: + logger.warning(f"No order found for {delivery_id}") + + except Exception as sf_error: + logger.error(f"Update failed for order {delivery_id}: {sf_error}") + # Continue execution even if Salesforce update fails +``` + +You’ll also need to add the two imports that are found within this code to the top of the file. + +```python +import os +from simple_salesforce import Salesforce +``` + +With these imports, add `simple_salesforce` to your `requirements.txt` file, then install that package with the following command once again. + +```bash +pip install -r requirements.txt +``` + +![Image of delivery tracker app](/img/bolt-python/delivery-tracker-main.png) + +## Testing your app + +Test your app one last time, and you’re done! + +Congratulations! You’ve built an app using [Bolt for Python](/tools/bolt-python/) that allows you to send information into Slack, as well as into a third-party service. While there are more features you can add to make this a more robust app, we hope that this serves as a good introduction into connecting services like Salesforce using Slack as a conduit. \ No newline at end of file diff --git a/docs/img/announce.gif b/docs/img/announce.gif new file mode 100644 index 000000000..7602784ce Binary files /dev/null and b/docs/img/announce.gif differ diff --git a/docs/img/basic-information-page.png b/docs/img/basic-information-page.png new file mode 100644 index 000000000..0467c01f5 Binary files /dev/null and b/docs/img/basic-information-page.png differ diff --git a/docs/img/bot-token.png b/docs/img/bot-token.png new file mode 100644 index 000000000..8fa723b15 Binary files /dev/null and b/docs/img/bot-token.png differ diff --git a/docs/img/delivery-tracker-main.png b/docs/img/delivery-tracker-main.png new file mode 100644 index 000000000..b8d2e885c Binary files /dev/null and b/docs/img/delivery-tracker-main.png differ diff --git a/docs/japanese/concepts/acknowledge.md b/docs/japanese/concepts/acknowledge.md new file mode 100644 index 000000000..36ba6cba4 --- /dev/null +++ b/docs/japanese/concepts/acknowledge.md @@ -0,0 +1,27 @@ +# リクエストの確認 + +アクション(action)、コマンド(command)、ショートカット(shortcut)、オプション(options)、およびモーダルからのデータ送信(view_submission)の各リクエストは、**必ず** `ack()` 関数を使って確認を行う必要があります。これによってリクエストが受信されたことが Slack に認識され、Slack のユーザーインターフェイスが適切に更新されます。 + +リクエストの種類によっては、確認で通知方法が異なる場合があります。例えば、外部データソースを使用する選択メニューのオプションのリクエストに対する確認では、適切な[オプション](/reference/block-kit/composition-objects/option-object)のリストとともに `ack()` を呼び出します。モーダルからのデータ送信に対する確認では、 `response_action` を渡すことで[モーダルの更新](/tools/bolt-python/concepts/view-submissions)などを行えます。 + +確認までの猶予は 3 秒しかないため、新しいメッセージの送信やデータベースからの情報の取得といった時間のかかる処理は、`ack()` を呼び出した後で行うことをおすすめします。 + + FaaS / serverless 環境を使う場合、 `ack()` するタイミングが異なります。 これに関する詳細は [Lazy listeners (FaaS)](/tools/bolt-python/concepts/lazy-listeners) を参照してください。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +# 外部データを使用する選択メニューオプションに応答するサンプル +@app.options("menu_selection") +def show_menu_options(ack): + options = [ + { + "text": {"type": "plain_text", "text":"Option 1"}, + "value":"1-1", + }, + { + "text": {"type": "plain_text", "text":"Option 2"}, + "value":"1-2", + }, + ] + ack(options=options) +``` diff --git a/docs/japanese/concepts/actions.md b/docs/japanese/concepts/actions.md new file mode 100644 index 000000000..8f1d1180e --- /dev/null +++ b/docs/japanese/concepts/actions.md @@ -0,0 +1,70 @@ +# アクション + +## アクションのリスニング + +Bolt アプリは `action` メソッドを用いて、ボタンのクリック、メニューの選択、メッセージショートカットなどのユーザーのアクションをリッスンすることができます。 + +アクションは `str` 型または `re.Pattern` 型の `action_id` でフィルタリングできます。`action_id` は、Slack プラットフォーム上のインタラクティブコンポーネントを区別する一意の識別子として機能します。 + +`action()` を使ったすべての例で `ack()` が使用されていることに注目してください。アクションのリスナー内では、Slack からのリクエストを受信したことを確認するために、`ack()` 関数を呼び出す必要があります。これについては、[リクエストの確認](/tools/bolt-python/concepts/acknowledge)セクションで説明しています。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +# 'approve_button' という action_id のブロックエレメントがトリガーされるたびに、このリスナーが呼び出させれる +@app.action("approve_button") +def update_message(ack): + ack() + # アクションへの反応としてメッセージを更新 +``` + +### 制約付きオブジェクトを使用したアクションのリスニング + +制約付きのオブジェクトを使用すると、`block_id` と `action_id` をそれぞれ、または任意に組み合わせてリッスンできます。オブジェクト内の制約は、`str` 型または `re.Pattern` 型で指定できます。 + +```python +# この関数は、block_id が 'assign_ticket' に一致し +# かつ action_id が 'select_user' に一致する場合にのみ呼び出される +@app.action({ + "block_id": "assign_ticket", + "action_id": "select_user" +}) +def update_message(ack, body, client): + ack() + + if "container" in body and "message_ts" in body["container"]: + client.reactions_add( + name="white_check_mark", + channel=body["channel"]["id"], + timestamp=body["container"]["message_ts"], + ) +``` + +## アクションへの応答 + +アクションへの応答には、主に 2 つの方法があります。1 つ目の最も一般的なやり方は `say()` を使用する方法です。そのリクエストが発生した会話(チャンネルや DM)にメッセージを返します。 + +2 つ目は、`respond()` を使用する方法です。これは、アクションに関連づけられた `response_url` を使ったメッセージ送信を行うためのユーティリティです。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +# 'approve_button' という action_id のインタラクティブコンポーネントがトリガーされると、このリスナーが呼ばれる +@app.action("approve_button") +def approve_request(ack, say): + # アクションのリクエストを確認 + ack() + say("Request approved 👍") +``` + +### respond() の利用 + +`respond()` は `response_url` を使って送信するときに便利なメソッドで、これらと同じような動作をします。投稿するメッセージのペイロードには、全ての[メッセージペイロードのプロパティ](/messaging/#payloads)とオプションのプロパティとして `response_type`(値は `"in_channel"` または `"ephemeral"`)、`replace_original`、`delete_original`、`unfurl_links`、`unfurl_media` などを指定できます。こうすることによってアプリから送信されるメッセージは、やり取りの発生元に反映されます。 + +```python +# 'user_select' という action_id を持つアクションのトリガーをリッスン +@app.action("user_select") +def select_user(ack, action, respond): + ack() + respond(f"You selected <@{action['selected_user']}>") +``` diff --git a/docs/japanese/concepts/adapters.md b/docs/japanese/concepts/adapters.md new file mode 100644 index 000000000..6ed804c26 --- /dev/null +++ b/docs/japanese/concepts/adapters.md @@ -0,0 +1,38 @@ +# アダプター + +アダプターは Slack から届く受信リクエストの受付とパーズを担当し、それらのリクエストを [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py) の形式に変換して Bolt アプリに引き渡します。 + +デフォルトでは、Bolt の組み込みの [`HTTPServer`](https://docs.python.org/3/library/http.server.html) アダプターが使われます。このアダプターは、ローカルで開発するのには問題がありませんが、**本番環境での利用は推奨されていません**。Bolt for Python には複数の組み込みのアダプターが用意されており、必要に応じてインポートしてアプリで使用することができます。組み込みのアダプターは Flask、Django、Starlette をはじめとする様々な人気の Python フレームワークをサポートしています。これらのアダプターは、あなたが選択した本番環境で利用可能な Webサーバーとともに利用することができます。 + +アダプターを使用するには、任意のフレームワークを使ってアプリを開発し、そのコードに対応するアダプターをインポートします。その後、アダプターのインスタンスを初期化して、受信リクエストの受付とパーズを行う関数を呼び出します。 + +すべてのアダプターの一覧と、設定や使い方のサンプルは、リポジトリの [`examples` フォルダ](https://github.com/slackapi/bolt-python/tree/main/examples)をご覧ください。 + +```python +from slack_bolt import App +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + token=os.environ.get("SLACK_BOT_TOKEN") +) + +# ここには Flask 固有の記述はありません +# App はフレームワークやランタイムに一切依存しません +@app.command("/hello-bolt") +def hello(body, ack): + ack(f"Hi <@{body['user_id']}>!") + +# Flask アプリを初期化します +from flask import Flask, request +flask_app = Flask(__name__) + +# SlackRequestHandler は WSGI のリクエストを Bolt のインターフェイスに合った形に変換します +# Bolt レスポンスからの WSGI レスポンスの作成も行います +from slack_bolt.adapter.flask import SlackRequestHandler +handler = SlackRequestHandler(app) + +# Flask アプリへのルートを登録します +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + # handler はアプリのディスパッチメソッドを実行します + return handler.handle(request) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/app-home.md b/docs/japanese/concepts/app-home.md new file mode 100644 index 000000000..d950221ad --- /dev/null +++ b/docs/japanese/concepts/app-home.md @@ -0,0 +1,40 @@ +# ホームタブの更新 + +[ホームタブ](/surfaces/app-home)は、サイドバーや検索画面からアクセス可能なサーフェスエリアです。アプリはこのエリアを使ってユーザーごとのビューを表示することができます。アプリ設定ページで App Home の機能を有効にすると、[`views.publish`](/reference/methods/views.publish) API メソッドの呼び出しで `user_id` と[ビューのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)を指定して、ホームタブを公開・更新することができるようになります。 + +[`app_home_opened`](/reference/events/app_home_opened) イベントをサブスクライブすると、ユーザーが App Home を開く操作をリッスンできます。 + +指定可能な引数の一覧は [モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +@app.event("app_home_opened") +def update_home_tab(client, event, logger): + try: + # 組み込みのクライアントを使って views.publish を呼び出す + client.views_publish( + # イベントに関連づけられたユーザー ID を使用 + user_id=event["user"], + # アプリの設定で予めホームタブが有効になっている必要がある + view={ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Welcome home, <@" + event["user"] + "> :house:*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text":"Learn how home tabs can be more useful and interactive ." + } + } + ] + } + ) + except Exception as e: + logger.error(f"Error publishing home tab: {e}") +``` \ No newline at end of file diff --git a/docs/japanese/concepts/async.md b/docs/japanese/concepts/async.md new file mode 100644 index 000000000..6687dcff5 --- /dev/null +++ b/docs/japanese/concepts/async.md @@ -0,0 +1,67 @@ +# Async(asyncio)の使用 + +非同期バージョンの Bolt を使用する場合は、`App` の代わりに `AsyncApp` インスタンスをインポートして初期化します。`AsyncApp` では [AIOHTTP](https://docs.aiohttp.org/) を使って API リクエストを行うため、`aiohttp` をインストールする必要があります(`requirements.txt` に追記するか、`pip install aiohttp` を実行します)。 + +非同期バージョンのプロジェクトのサンプルは、リポジトリの [`examples` フォルダ](https://github.com/slackapi/bolt-python/tree/main/examples)にあります。 + +```python +# aiohttp のインストールが必要です +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +@app.event("app_mention") +async def handle_mentions(event, client, say): # 非同期関数 + api_response = await client.reactions_add( + channel=event["channel"], + timestamp=event["ts"], + name="eyes", + ) + await say("What's up?") + +if __name__ == "__main__": + app.start(3000) +``` + +## 他のフレームワークを使用する + +`AsyncApp#start()` では内部的に [`AIOHTTP`](https://docs.aiohttp.org/) のWebサーバーが実装されています。必要に応じて、受信リクエストの処理に `AIOHTTP` 以外のフレームワークを使用することができます。 + +この例では [Sanic](https://sanicframework.org/) を使用しています。すべてのアダプターのリストについては、[`adapter` フォルダ](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) を参照してください。 + +以下のコマンドを実行すると、必要なパッケージをインストールして、Sanic サーバーをポート 3000 で起動します。 + +```bash +# 必要なパッケージをインストールします +pip install slack_bolt sanic uvicorn +# ソースファイルを async_app.py として保存します +uvicorn async_app:api --reload --port 3000 --log-level debug +``` + +```python +from slack_bolt.async_app import AsyncApp +app = AsyncApp() + +# ここには Sanic に固有の記述はありません +# AsyncApp はフレームワークやランタイムに依存しません +@app.event("app_mention") +async def handle_app_mentions(say): + await say("What's up?") + +import os +from sanic import Sanic +from sanic.request import Request +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +# App のインスタンスから Sanic 用のアダプターを作成します +app_handler = AsyncSlackRequestHandler(app) +# Sanic アプリを作成します +api = Sanic(name="awesome-slack-app") + +@api.post("/slack/events") +async def endpoint(req: Request): + # app_handler では内部的にアプリのディスパッチメソッドが実行されます + return await app_handler.handle(req) + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/authenticating-oauth.md b/docs/japanese/concepts/authenticating-oauth.md new file mode 100644 index 000000000..e09443d64 --- /dev/null +++ b/docs/japanese/concepts/authenticating-oauth.md @@ -0,0 +1,86 @@ +# OAuth を使った認証 + +Slack アプリを複数のワークスペースにインストールできるようにするためには、OAuth フローを実装した上で、アクセストークンなどのインストールに関する情報をセキュアな方法で保存する必要があります。アプリを初期化する際に `client_id`、`client_secret`、`scopes`、`installation_store`、`state_store` を指定することで、OAuth のエンドポイントのルート情報や stateパラメーターの検証をBolt for Python にハンドリングさせることができます。カスタムのアダプターを実装する場合は、SDK が提供する組み込みの[OAuth ライブラリ](/tools/python-slack-sdk/oauth/)を利用するのが便利です。これは Slack が開発したモジュールで、Bolt for Python 内部でも利用しています。 + +Bolt for Python によって `slack/oauth_redirect` という**リダイレクト URL** が生成されます。Slack はアプリのインストールフローを完了させたユーザーをこの URL にリダイレクトします。この**リダイレクト URL** は、アプリの設定の「**OAuth and Permissions**」であらかじめ追加しておく必要があります。この URL は、後ほど説明するように `OAuthSettings` というコンストラクタの引数で指定することもできます。 + +Bolt for Python は `slack/install` というルートも生成します。これはアプリを直接インストールするための「**Add to Slack**」ボタンを表示するために使われます。すでにワークスペースへのアプリのインストールが済んでいる場合に追加で各ユーザーのユーザートークンなどの情報を取得する場合や、カスタムのインストール用の URL を動的に生成したい場合などは、`oauth_settings` の `authorize_url_generator` でカスタムの URL ジェネレーターを指定することができます。 + +バージョン 1.1.0 以降の Bolt for Python では、[OrG 全体へのインストール](/enterprise)がデフォルトでサポートされています。OrG 全体へのインストールは、アプリの設定の「**Org Level Apps**」で有効化できます。 + +Slack での OAuth を使ったインストールフローについて詳しくは、[API ドキュメントを参照してください](/authentication/installing-with-oauth)。 + +```python +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +oauth_settings = OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + scopes=["channels:read", "groups:read", "chat:write"], + installation_store=FileInstallationStore(base_dir="./data/installations"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states") +) + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + oauth_settings=oauth_settings +) +``` + +## OAuth デフォルト設定をカスタマイズ + +`oauth_settings` を使って OAuth モジュールのデフォルト設定を上書きすることができます。このカスタマイズされた設定は App の初期化時に渡します。以下の情報を変更可能です: + +- `install_path` : 「Add to Slack」ボタンのデフォルトのパスを上書きするために使用 +- `redirect_uri` : リダイレクト URL のデフォルトのパスを上書きするために使用 +- `callback_options` : OAuth フローの最後に表示するカスタムの成功ページと失敗ページの表示処理を提供するために使用 +- `state_store` : 組み込みの `FileOAuthStateStore` に代わる、カスタムの stateに関するデータストアを指定するために使用 +- `installation_store` : 組み込みの `FileInstallationStore` に代わる、カスタムのデータストアを指定するために使用 + +```python +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.response import BoltResponse + +def success(args:SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse( + status=200, # ユーザーをリダイレクトすることも可能 + body="Your own response to end-users here" + ) + +def failure(args:FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse( + status=args.suggested_status_code, + body="Your own response to end-users here" + ) + +callback_options = CallbackOptions(success=success, failure=failure) + +import os +from slack_bolt import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + installation_store=FileInstallationStore(base_dir="./data/installations"), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["app_mentions:read", "channels:history", "im:history", "chat:write"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states"), + callback_options=callback_options, + ), +) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/authorization.md b/docs/japanese/concepts/authorization.md new file mode 100644 index 000000000..b6a14b30a --- /dev/null +++ b/docs/japanese/concepts/authorization.md @@ -0,0 +1,64 @@ +# 認可(Authorization) + +認可(Authorization)は、Slack からの受信リクエストを処理するにあたって、どのようなSlack +クレデンシャル (ボットトークンなど) を使用可能にするかを決定するプロセスです。 + +単一のワークスペースにインストールされるアプリでは、`token` パラメーターを使って `App` のコンストラクターにボットトークンを渡すという、シンプルな方法が使えます。それに対して、複数のワークスペースにインストールされるアプリでは、次の 2 つの方法のいずれかを使用する必要があります。簡単なのは、組み込みの OAuth サポートを使用する方法です。OAuth サポートは、OAuth フロー用のURLのセットアップとstateの検証を行います。詳細は「[OAuth を使った認証](/tools/bolt-python/concepts/authenticating-oauth)」セクションを参照してください。 + +よりカスタマイズできる方法として、`App` をインスタンス化する関数に`authorize` パラメーターを指定する方法があります。`authorize` 関数から返される [`AuthorizeResult` のインスタンス](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py)には、どのユーザーがどこで発生させたリクエストかを示す情報が含まれます。 + +`AuthorizeResult` には、いくつか特定のプロパティを指定する必要があり、いずれも `str` 型です。 + +- **`bot_token`**(xoxb)*または* **`user_token`**(xoxp): どちらか一方が**必須**です。ほとんどのアプリでは、デフォルトの `bot_token` を使用すればよいでしょう。トークンを渡すことで、`say()` などの組み込みの関数を機能させることができます。 +- **`bot_user_id`** および **`bot_id`** : `bot_token` を使用する場合に指定します。 +- **`enterprise_id`** および **`team_id`** : アプリに届いたリクエストから見つけることができます。 +- **`user_id`** : `user_token` を使用する場合に必須です。 + +```python +import os +from slack_bolt import App +# AuthorizeResult クラスをインポートします +from slack_bolt.authorization import AuthorizeResult + +# これはあくまでサンプル例です(ユーザートークンがないことを想定しています) +# 実際にはセキュアな DB に認可情報を保存してください +installations = [ + { + "enterprise_id":"E1234A12AB", + "team_id":"T12345", + "bot_token": "xoxb-123abc", + "bot_id":"B1251", + "bot_user_id":"U12385" + }, + { + "team_id":"T77712", + "bot_token": "xoxb-102anc", + "bot_id":"B5910", + "bot_user_id":"U1239", + "enterprise_id":"E1234A12AB" + } +] + +def authorize(enterprise_id, team_id, logger): + # トークンを取得するためのあなたのロジックをここに記述します + for team in installations: + # 一部のチームは enterprise_id を持たない場合があります + is_valid_enterprise = "enterprise_id" not in team or enterprise_id == team["enterprise_id"] + if is_valid_enterprise and team["team_id"] == team_id: + # AuthorizeResult のインスタンスを返します + # bot_id と bot_user_id を保存していない場合、bot_token を使って `from_auth_test_response` を呼び出すと、自動的に取得できます + return AuthorizeResult( + enterprise_id=enterprise_id, + team_id=team_id, + bot_token=team["bot_token"], + bot_id=team["bot_id"], + bot_user_id=team["bot_user_id"] + ) + + logger.error("No authorization information was found") + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + authorize=authorize +) +``` diff --git a/docs/japanese/concepts/commands.md b/docs/japanese/concepts/commands.md new file mode 100644 index 000000000..ebb43c4d3 --- /dev/null +++ b/docs/japanese/concepts/commands.md @@ -0,0 +1,19 @@ +# コマンドのリスニングと応答 + +スラッシュコマンドが実行されたリクエストをリッスンするには、`command()` メソッドを使用します。このメソッドでは `str` 型の `command_name` の指定が必要です。 + +コマンドリクエストをアプリが受信し確認したことを Slack に通知するため、`ack()` を呼び出す必要があります。 + +スラッシュコマンドに応答する方法は 2 つあります。1 つ目は `say()` を使う方法で、文字列または JSON のペイロードを渡すことができます。2 つ目は `respond()` を使う方法です。これは `response_url` がある場合に活躍します。これらの方法は[アクションへの応答](/tools/bolt-python/concepts/actions)セクションで詳しく説明しています。 + +アプリの設定でコマンドを登録するときは、リクエスト URL の末尾に `/slack/events` をつけます。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +# echoコマンドは受け取ったコマンドをそのまま返す +@app.command("/echo") +def repeat_text(ack, respond, command): + # command リクエストを確認 + ack() + respond(f"{command['text']}") +``` \ No newline at end of file diff --git a/docs/japanese/concepts/context.md b/docs/japanese/concepts/context.md new file mode 100644 index 000000000..13a287728 --- /dev/null +++ b/docs/japanese/concepts/context.md @@ -0,0 +1,65 @@ +# コンテキストの追加 + +すべてのリスナーは `context` ディクショナリにアクセスできます。リスナーはこれを使ってリクエストの付加情報を得ることができます。受信リクエストに含まれる `user_id`、`team_id`、`channel_id`、`enterprise_id` などの情報は、Bolt によって自動的に設定されます。 + +`context` は単純なディクショナリで、変更を直接加えることもできます。 + +```python +# ユーザーID を使って外部のシステムからタスクを取得するリスナーミドルウェア +def fetch_tasks(context, event, next): + user = event["user"] + try: + # get_tasks は、ユーザー ID に対応するタスクのリストを DB から取得します + user_tasks = db.get_tasks(user) + tasks = user_tasks + except Exception: + # タスクが見つからなかった場合 get_tasks() は例外を投げます + tasks = [] + finally: + # ユーザーのタスクを context に設定します + context["tasks"] = tasks + next() + +# section のブロックのリストを作成するリスナーミドルウェア +def create_sections(context, next): + task_blocks = [] + # 先ほどのミドルウェアを使って context に追加した各タスクについて、処理を繰り返します + for task in context["tasks"]: + task_blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{task['title']}* +{task['body']}" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text":"See task" + }, + "url": task["url"], + } + } + ) + # ブロックのリストを context に設定します + context["blocks"] = task_blocks + next() + +# ユーザーがアプリのホームを開くのをリッスンします +# fetch_tasks ミドルウェアを含めます +@app.event( + event = "app_home_opened", + middleware = [fetch_tasks, create_sections] +) +def show_tasks(event, client, context): + # ユーザーのホームタブにビューを表示します + client.views_publish( + user_id=event["user"], + view={ + "type": "home", + "blocks": context["blocks"] + } + ) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/custom-adapters.md b/docs/japanese/concepts/custom-adapters.md new file mode 100644 index 000000000..584893511 --- /dev/null +++ b/docs/japanese/concepts/custom-adapters.md @@ -0,0 +1,65 @@ +# カスタムのアダプター + +[アダプター](/tools/bolt-python/concepts/adapters)はフレキシブルで、あなたが使用したいフレームワークに合わせた調整も可能です。アダプターでは、次の 2 つの要素が必須となっています。 + +- `__init__(app:App)` : コンストラクター。Bolt の `App` のインスタンスを受け取り、保持します。 +- `handle(req:Request)` : Slack からの受信リクエストを受け取り、解析を行う関数。通常は `handle()` という名前です。リクエストを [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py) のインスタンスに合った形にして、保持している Bolt アプリに引き渡します。 + +`BoltRequest` のインスタンスの作成では、以下の 4 種類のパラメーターを指定できます。 + +| パラメーター | 説明 | 必須 | +|-----------|-------------|-----------| +| `body: str` | そのままのリクエストボディ | **Yes** | +| `query: any` | クエリストリング | No | +| `headers:Dict[str, Union[str, List[str]]]` | リクエストヘッダー | No | +| `context:BoltContext` | リクエストのコンテキスト情報 | No | + +アダプターは、Bolt アプリからの [`BoltResponse` のインスタンス](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py)を返します。 + +カスタムのアダプターに関連した詳しいサンプルについては、[組み込みのアダプター](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter)の実装を参考にしてください。 + +```python +# Flask で必要なパッケージをインポートします +from flask import Request, Response, make_response + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + +# この例は Flask アダプターを簡略化したものです +# もう少し詳しい完全版のサンプルは、adapter フォルダをご覧ください +# github.com/slackapi/bolt-python/blob/main/slack_bolt/adapter/flask/handler.py + +# HTTP リクエストを取り込み、標準の BoltRequest に変換します +def to_bolt_request(req:Request) -> BoltRequest: + return BoltRequest( + body=req.get_data(as_text=True), + query=req.query_string.decode("utf-8"), + headers=req.headers, + ) + +# BoltResponse を取り込み、標準の Flask レスポンスに変換します +def to_flask_response(bolt_resp:BoltResponse) -> Response: + resp:Response = make_response(bolt_resp.body, bolt_resp.status) + for k, values in bolt_resp.headers.items(): + for v in values: + resp.headers.add_header(k, v) + return resp + +# アプリからインスタンス化します +# Flask アプリを受け取ります +class SlackRequestHandler: + def __init__(self, app:App): + self.app = app + + # Slack からリクエストが届いたときに + # Flask アプリの handle() を呼び出します + def handle(self, req:Request) -> Response: + # この例では OAuth に関する部分は扱いません + if req.method == "POST": + # Bolt へのリクエストをディスパッチし、処理とルーティングを行います + bolt_resp:BoltResponse = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/errors.md b/docs/japanese/concepts/errors.md new file mode 100644 index 000000000..2e8e27f90 --- /dev/null +++ b/docs/japanese/concepts/errors.md @@ -0,0 +1,12 @@ +# エラーの処理 + +リスナー内でエラーが発生した場合に try/except ブロックを使用して直接エラーを処理することができます。アプリに関連するエラーは、`BoltError` 型です。Slack API の呼び出しに関連するエラーは、`SlackApiError` 型となります。 + +デフォルトでは、すべての処理されなかった例外のログはグローバルのエラーハンドラーによってコンソールに出力されます。グローバルのエラーを開発者自身で処理するには、`app.error(fn)` 関数を使ってグローバルのエラーハンドラーをアプリに設定します。 + +```python +@app.error +def custom_error_handler(error, body, logger): + logger.exception(f"Error: {error}") + logger.info(f"Request body: {body}") +``` \ No newline at end of file diff --git a/docs/japanese/concepts/event-listening.md b/docs/japanese/concepts/event-listening.md new file mode 100644 index 000000000..7b21f1fa4 --- /dev/null +++ b/docs/japanese/concepts/event-listening.md @@ -0,0 +1,33 @@ +# イベントのリスニング + +`event()` メソッドを使うと、[Events API](/reference/events) の任意のイベントをリッスンできます。リッスンするイベントは、アプリの設定であらかじめサブスクライブしておく必要があります。これを利用することで、アプリがインストールされたワークスペースで何らかのイベント(例:ユーザーがメッセージにリアクションをつけた、ユーザーがチャンネルに参加した)が発生したときに、アプリに何らかのアクションを実行させることができます。 + +`event()` メソッドには `str` 型の `eventType` を指定する必要があります。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +# ユーザーがワークスペースに参加した際に、自己紹介を促すメッセージを指定のチャンネルに送信 +@app.event("team_join") +def ask_for_introduction(event, say): + welcome_channel_id = "C12345" + user_id = event["user"] + text = f"Welcome to the team, <@{user_id}>! 🎉 You can introduce yourself in this channel." + say(text=text, channel=welcome_channel_id) +``` + +## メッセージのサブタイプのフィルタリング + +`message()` リスナーは `event("message")` と等価の機能を提供します。 + +`subtype` という追加のキーを指定して、イベントのサブタイプでフィルタリングすることもできます。よく使われるサブタイプには、`bot_message` や `message_replied` があります。詳しくは[メッセージイベントページ](/reference/events/message#subtypes)を参照してください。サブタイプなしのイベントだけにフィルターするために明に `None` を指定することもできます。 + +```python +# 変更されたすべてのメッセージに一致 +@app.event({ + "type": "message", + "subtype": "message_changed" +}) +def log_message_change(logger, event): + user, text = event["user"], event["text"] + logger.info(f"The user {user} changed the message to {text}") +``` \ No newline at end of file diff --git a/docs/japanese/concepts/global-middleware.md b/docs/japanese/concepts/global-middleware.md new file mode 100644 index 000000000..01ca417ac --- /dev/null +++ b/docs/japanese/concepts/global-middleware.md @@ -0,0 +1,29 @@ +# グローバルミドルウェア + +グローバルミドルウェアは、すべての受信リクエストに対して、リスナーミドルウェアが呼ばれる前に実行されるものです。ミドルウェア関数を `app.use()` に渡すことで、アプリにはグローバルミドルウェアをいくつでも追加できます。ミドルウェア関数で受け取れる引数はリスナー関数と同じものに加えて`next()` 関数があります。 + +グローバルミドルウェアでもリスナーミドルウェアでも、次のミドルウェアに実行チェーンの制御をリレーするために、`next()` を呼び出す必要があります。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +@app.use +def auth_abc(client, context, logger, payload, next): + slack_user_id = payload["user"] + help_channel_id = "C12345" + + try: + # Slack のユーザー ID を使って外部のシステムでユーザーを検索します + user = abc.lookup_by_id(slack_user_id) + # 結果を context に保存します + context["user"] = user + except Exception: + client.chat_postEphemeral( + channel=payload["channel"], + user=slack_user_id, + text=f"Sorry <@{slack_user_id}>, you aren't registered in ABC or there was an error with authentication.Please post in <#{help_channel_id}> for assistance" + ) + + # 次のミドルウェアに実行権を渡します + next() +``` \ No newline at end of file diff --git a/docs/japanese/concepts/lazy-listeners.md b/docs/japanese/concepts/lazy-listeners.md new file mode 100644 index 000000000..029f61d99 --- /dev/null +++ b/docs/japanese/concepts/lazy-listeners.md @@ -0,0 +1,100 @@ +# Lazy リスナー(FaaS + +Lazy リスナー関数は、FaaS 環境への Slack アプリのデプロイを容易にする機能です。この機能は Bolt for Python でのみ利用可能で、他の Bolt フレームワークでこの機能に対応することは予定していません。 + +通常、アクション(action)、コマンド(command)、ショートカット(shortcut)、オプション(options)、およびモーダルからのデータ送信(view_submission)をハンドルするとき、 `ack()` を呼び出し、Slack からのリクエストを 3 秒以内に確認する必要があります。`ack()` を呼び出すと Slack に HTTP ステータスが 200 OK の応答が返されます。こうすることで、アプリがリクエストの応答を処理中であることを Slack に伝えられます。通常であれば、この確認処理を処理関数の最初のステップとして行うことを推奨しています。 + +しかし、FaaS 環境や類似のランタイムで実行されるアプリでは、 **HTTP レスポンスを返したあとにスレッドやプロセスの実行を続けることができない** ため、確認の応答を送信した後で時間のかかる処理をするという通常のパターンに従うことができません。こうした環境で動作させるためには、 `process_before_response` フラグを `True` に設定します。このフラグが `True` に設定されている場合、Bolt はリスナー関数での処理が完了するまで HTTP レスポンスの送信を遅延させます。そのため 3 秒以内にリスナーのすべての処理が完了しなかった場合は Slack 上でタイムアウトのエラー表示となってしまいます。また、Events API に応答するリスナーでは明示的な `ack()` メソッドの呼び出しを必要としませんが、この設定を有効にしている場合、リスナーの処理を 3 秒以内に完了させる必要があることにも注意してください。 + +処理関数の中で時間のかかる処理を実行できるようにするために、私たちは Lazy リスナーという関数を実行する仕組みを導入しました。Lazy リスナーは、デコレーターとして動作させるのではなく、以下の 2 つのキーワード引数を受け取ります。 +* `ack: Callable`: 3 秒以内での `ack()` メソッドの呼び出しを担当します。 +* `lazy: List[Callable]` : リクエストに関する時間のかかる処理のハンドリングを担当します。Lazy 関数からは `ack()` にアクセスすることはできません。 + +```python +def respond_to_slack_within_3_seconds(body, ack): + text = body.get("text") + if text is None or len(text) == 0: + ack(f":x:Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # 3 秒より長い時間を指定します + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + # この場合でも ack() は 3 秒以内に呼ばれます + ack=respond_to_slack_within_3_seconds, + # Lazy 関数がイベントの処理を担当します + lazy=[run_long_process] +) +``` + +## AWS Lambda を使用した例 + +このサンプルは、[AWS Lambda](https://aws.amazon.com/lambda/) にコードをデプロイします。[`examples` フォルダ](https://github.com/slackapi/bolt-python/tree/main/examples/aws_lambda)にはほかにもサンプルが用意されています。 + +```bash +pip install slack_bolt +# ソースコードを main.py として保存します +# config.yaml を設定してハンドラーを `handler: main.handler` で参照できるようにします + +# https://pypi.org/project/python-lambda/ +pip install python-lambda + +# config.yml を適切に設定します +# lazy リスナーの実行には lambda:InvokeFunction と lambda:GetFunction が必要です +export SLACK_SIGNING_SECRET=*** +export SLACK_BOT_TOKEN=xoxb-*** +echo 'slack_bolt' > requirements.txt +lambda deploy --config-file config.yaml --requirements requirements.txt +``` + +```python +from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler + +# FaaS で実行するときは process_before_response を True にする必要があります +app = App(process_before_response=True) + +def respond_to_slack_within_3_seconds(body, ack): + text = body.get("text") + if text is None or len(text) == 0: + ack(":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + +import time +def run_long_process(respond, body): + time.sleep(5) # 3 秒より長い時間を指定します + respond(f"Completed! (task: {body['text']})") + +app.command("/start-process")( + ack=respond_to_slack_within_3_seconds, # `ack()` の呼び出しを担当します + lazy=[run_long_process] # `ack()` の呼び出しはできません。複数の関数を持たせることができます。 +) + +def handler(event, context): + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context) +``` + +このサンプルアプリを実行するには、以下の IAM 権限が必要になります。 + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "lambda:GetFunction" + ], + "Resource": "*" + } + ] +} +``` \ No newline at end of file diff --git a/docs/japanese/concepts/listener-middleware.md b/docs/japanese/concepts/listener-middleware.md new file mode 100644 index 000000000..425ae4ea7 --- /dev/null +++ b/docs/japanese/concepts/listener-middleware.md @@ -0,0 +1,31 @@ +# リスナーミドルウェア + +リスナーミドルウェアは、それを渡したリスナーでのみ実行されるミドルウェアです。リスナーには、`middleware` パラメーターを使ってミドルウェア関数をいくつでも渡すことができます。このパラメーターには、1 つまたは複数のミドルウェア関数からなるリストを指定します。 + +非常にシンプルなリスナーミドルウェアの場合であれば、`next()` メソッドを呼び出す代わりに `bool` 値(処理を継続したい場合は `True`)を返すだけで済む「リスナーマッチャー」を使うとよいでしょう。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +# ボットからのメッセージをフィルタリングするリスナーミドルウェア +def no_bot_messages(message, next): + if "bot_id" not in message: + next() + +# このリスナーは人間によって送信されたメッセージのみを受け取ります +@app.event(event="message", middleware=[no_bot_messages]) +def log_message(logger, event): + logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") + +# リスナーマッチャー: 簡略化されたバージョンのリスナーミドルウェア +def no_bot_messages(message) -> bool: + return "bot_id" not in message + +@app.event( + event="message", + matchers=[no_bot_messages] + # or matchers=[lambda message: message.get("subtype") != "bot_message"] +) +def log_message(logger, event): + logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") +``` diff --git a/docs/japanese/concepts/logging.md b/docs/japanese/concepts/logging.md new file mode 100644 index 000000000..3afa46539 --- /dev/null +++ b/docs/japanese/concepts/logging.md @@ -0,0 +1,21 @@ +# ロギング + +デフォルトでは、アプリからのログ情報は、既定の出力先に出力されます。`logging` モジュールをインポートすれば、`basicConfig()` の `level` パラメーターでrootのログレベルを変更することができます。指定できるログレベルは、重要度の低い方から `debug`、`info`、`warning`、`error`、および `critical` です。 + +グローバルのコンテキストとは別に、指定のログレベルに応じて単一のメッセージをログ出力することもできます。Bolt では [Python 標準の logging モジュール](https://docs.python.org/3/library/logging.html)が使われているため、このモジュールが持つすべての機能を利用できます。 + +```python +import logging + +# グローバルのコンテキストの logger です +# logging をインポートする必要があります +logging.basicConfig(level=logging.DEBUG) + +@app.event("app_mention") +def handle_mention(body, say, logger): + user = body["event"]["user"] + # 単一の logger の呼び出しです + # グローバルの logger がリスナーに渡されています + logger.debug(body) + say(f"{user} mentioned your app") +``` \ No newline at end of file diff --git a/docs/japanese/concepts/message-listening.md b/docs/japanese/concepts/message-listening.md new file mode 100644 index 000000000..dae729b51 --- /dev/null +++ b/docs/japanese/concepts/message-listening.md @@ -0,0 +1,28 @@ +# メッセージのリスニング + +[あなたのアプリがアクセス権限を持つ](/messaging/retrieving-messages)メッセージの投稿イベントをリッスンするには `message()` メソッドを利用します。このメソッドは `type` が `message` ではないイベントを処理対象から除外します。 + +`message()` の引数には `str` 型または `re.Pattern` オブジェクトを指定できます。この条件のパターンに一致しないメッセージは除外されます。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +# '👋' が含まれるすべてのメッセージに一致 +@app.message(":wave:") +def say_hello(message, say): + user = message['user'] + say(f"Hi there, <@{user}>!") +``` + +## 正規表現パターンの利用 + +文字列の代わりに `re.compile()` メソッドを使用すれば、より細やかな条件指定ができます。 + +```python +import re + +@app.message(re.compile("(hi|hello|hey)")) +def say_hello_regex(say, context): + # 正規表現のマッチ結果は context.matches に設定される + greeting = context['matches'][0] + say(f"{greeting}, how are you?") +``` \ No newline at end of file diff --git a/docs/japanese/concepts/message-sending.md b/docs/japanese/concepts/message-sending.md new file mode 100644 index 000000000..ace67051b --- /dev/null +++ b/docs/japanese/concepts/message-sending.md @@ -0,0 +1,41 @@ +# メッセージの送信 + +リスナー関数内では、関連づけられた会話(例:リスナー実行のトリガーとなったイベントまたはアクションの発生元の会話)がある場合はいつでも `say()` を使用できます。`say()` には文字列または JSON ペイロードを指定できます。文字列の場合、送信できるのはテキストベースの単純なメッセージです。より複雑なメッセージを送信するには JSON ペイロードを指定します。指定したメッセージのペイロードは、関連づけられた会話内のメッセージとして送信されます。 + +リスナー関数の外でメッセージを送信したい場合や、より高度な処理(特定のエラーの処理など)を実行したい場合は、[Bolt インスタンスにアタッチされたクライアント](/tools/bolt-python/concepts/web-api)の `client.chat_postMessage` を呼び出します。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +# 'knock knock' が含まれるメッセージをリッスンし、イタリック体で 'Who's there?' と返信 +@app.message("knock knock") +def ask_who(message, say): + say("_Who's there?_") +``` + +## ブロックを用いたメッセージの送信 + +`say()` は、より複雑なメッセージペイロードを受け付けるので、メッセージに機能やリッチな構造を与えることが容易です。 + +リッチなメッセージレイアウトをアプリに追加する方法については、[API サイトのガイド](/messaging/#structure)を参照してください。また、[Block Kit ビルダー](https://api.slack.com/tools/block-kit-builder?template=1)の一般的なアプリフローのテンプレートも見てみてください。 + +```python +# ユーザーが 📅 のリアクションをつけたら、日付ピッカーのついた section ブロックを送信 +@app.event("reaction_added") +def show_datepicker(event, say): + reaction = event["reaction"] + if reaction == "calendar": + blocks = [{ + "type": "section", + "text": {"type": "mrkdwn", "text":"Pick a date for me to remind you"}, + "accessory": { + "type": "datepicker", + "action_id": "datepicker_remind", + "initial_date":"2020-05-04", + "placeholder": {"type": "plain_text", "text":"Select a date"} + } + }] + say( + blocks=blocks, + text="Pick a date for me to remind you" + ) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/opening-modals.md b/docs/japanese/concepts/opening-modals.md new file mode 100644 index 000000000..65342afb1 --- /dev/null +++ b/docs/japanese/concepts/opening-modals.md @@ -0,0 +1,51 @@ +# モーダルの開始 + +[モーダル](/surfaces/modals)は、ユーザーからのデータの入力を受け付けたり、動的な情報を表示したりするためのインターフェイスです。組み込みの APIクライアントの [`views.open`](/reference/methods/views.open/) メソッドに、有効な `trigger_id` と[ビューのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)を指定してモーダルを開始します。 + +ショートカットの実行、ボタンを押下、選択メニューの操作などの操作の場合、Request URL に送信されるペイロードには `trigger_id` が含まれます。 + +モーダルの生成方法についての詳細は、[API ドキュメント](/surfaces/modals#composing_views)を参照してください。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +# ショートカットの呼び出しをリッスン +@app.shortcut("open_modal") +def open_modal(ack, body, client): + # コマンドのリクエストを確認 + ack() + # 組み込みのクライアントで views_open を呼び出し + client.views_open( + # 受け取りから 3 秒以内に有効な trigger_id を渡す + trigger_id=body["trigger_id"], + # ビューのペイロード + view={ + "type": "modal", + # ビューの識別子 + "callback_id": "view_1", + "title": {"type": "plain_text", "text":"My App"}, + "submit": {"type": "plain_text", "text":"Submit"}, + "blocks": [ + { + "type": "section", + "text": {"type": "mrkdwn", "text":"Welcome to a modal with _blocks_"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text":"Click me!"}, + "action_id": "button_abc" + } + }, + { + "type": "input", + "block_id": "input_c", + "label": {"type": "plain_text", "text":"What are your hopes and dreams?"}, + "element": { + "type": "plain_text_input", + "action_id": "dreamy_input", + "multiline":True + } + } + ] + } + ) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/select-menu-options.md b/docs/japanese/concepts/select-menu-options.md new file mode 100644 index 000000000..4f3a5f357 --- /dev/null +++ b/docs/japanese/concepts/select-menu-options.md @@ -0,0 +1,32 @@ +# オプションのリスニングと応答 + +`options()` メソッドは、Slack からのオプション(セレクトメニュー内の動的な選択肢)をリクエストするペイロードをリッスンします。 [`action()` と同様に](/tools/bolt-python/concepts/actions)、文字列型の `action_id` または制約付きオブジェクトが必要です。 + +外部データソースを使って選択メニューをロードするためには、末部に `/slack/events` が付加された URL を Options Load URL として予め設定しておく必要があります。 + +`external_select` メニューでは `action_id` を指定することをおすすめしています。ただし、ダイアログを利用している場合、ダイアログが Block Kit に対応していないため、`callback_id` をフィルタリングするための制約オブジェクトを使用する必要があります。 + +オプションのリクエストに応答するときは、有効なオプションを含む `options` または `option_groups` のリストとともに `ack()` を呼び出す必要があります。API サイトにある[外部データを使用する選択メニューに応答するサンプル例](/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select)と、[ダイアログでの応答例](/legacy/legacy-dialogs/#dynamic_select_elements_external)を参考にしてください。 + +さらに、ユーザーが入力したキーワードに基づいたオプションを返すようフィルタリングロジックを適用することもできます。 これは `payload` という引数の ` value` の値に基づいて、それぞれのパターンで異なるオプションの一覧を返すように実装することができます。 Bolt for Python のすべてのリスナーやミドルウェアでは、[多くの有用な引数](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)にアクセスすることができますので、チェックしてみてください。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +# 外部データを使用する選択メニューオプションに応答するサンプル例 +@app.options("external_action") +def show_options(ack, payload): + options = [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ] + keyword = payload.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in options if keyword in o["text"]["text"]] + ack(options=options) +``` diff --git a/docs/japanese/concepts/shortcuts.md b/docs/japanese/concepts/shortcuts.md new file mode 100644 index 000000000..39fb10ba8 --- /dev/null +++ b/docs/japanese/concepts/shortcuts.md @@ -0,0 +1,90 @@ +# ショートカットのリスニングと応答 + +`shortcut()` メソッドは、[グローバルショートカット](/interactivity/implementing-shortcuts#global)と[メッセージショートカット](/interactivity/implementing-shortcuts#messages)の 2 つをサポートしています。 + +ショートカットは、いつでも呼び出せるアプリのエントリーポイントを提供するものです。グローバルショートカットは Slack のテキスト入力エリアや検索ウィンドウからアクセスできます。メッセージショートカットはメッセージのコンテキストメニューからアクセスできます。アプリは、ショートカットリクエストをリッスンするために `shortcut()` メソッドを使用します。このメソッドには `str` 型または `re.Pattern` 型の `callback_id` パラメーターを指定します。 + +ショートカットリクエストがアプリによって確認されたことを Slack に伝えるため、`ack()` を呼び出す必要があります。 + +ショートカットのペイロードには `trigger_id` が含まれます。アプリはこれを使って、ユーザーにやろうとしていることを確認するための[モーダルを開く](/tools/bolt-python/concepts/opening-modals)ことができます。 + +アプリの設定でショートカットを登録する際は、他の URL と同じように、リクエスト URL の末尾に `/slack/events` をつけます。 + +⚠️ グローバルショートカットのペイロードにはチャンネル ID が **含まれません**。アプリでチャンネル ID を取得する必要がある場合は、モーダル内に [`conversations_select`](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) エレメントを配置します。メッセージショートカットにはチャンネル ID が含まれます。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +# 'open_modal' という callback_id のショートカットをリッスン +@app.shortcut("open_modal") +def open_modal(ack, shortcut, client): + # ショートカットのリクエストを確認 + ack() + # 組み込みのクライアントを使って views_open メソッドを呼び出す + client.views_open( + trigger_id=shortcut["trigger_id"], + # モーダルで表示するシンプルなビューのペイロード + view={ + "type": "modal", + "title": {"type": "plain_text", "text":"My App"}, + "close": {"type": "plain_text", "text":"Close"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text":"Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` + +## 制約付きオブジェクトを使用したショートカットのリスニング + +制約付きオブジェクトを使って `callback_id` や `type` によるリッスンできます。オブジェクト内の制約は `str` 型または `re.Pattern` オブジェクトを使用できます。 + +```python +# このリスナーが呼び出されるのは、callback_id が 'open_modal' と一致し +# かつ type が 'message_action' と一致するときのみ +@app.shortcut({"callback_id": "open_modal", "type": "message_action"}) +def open_modal(ack, shortcut, client): + # ショートカットのリクエストを確認 + ack() + # 組み込みのクライアントを使って views_open メソッドを呼び出す + client.views_open( + trigger_id=shortcut["trigger_id"], + view={ + "type": "modal", + "title": {"type": "plain_text", "text":"My App"}, + "close": {"type": "plain_text", "text":"Close"}, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text":"About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text":"Psssst this modal was designed using " + } + ] + } + ] + } + ) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/socket-mode.md b/docs/japanese/concepts/socket-mode.md new file mode 100644 index 000000000..92922d2de --- /dev/null +++ b/docs/japanese/concepts/socket-mode.md @@ -0,0 +1,57 @@ +# ソケットモードの利用 + +[ソケットモード](/apis/events-api/using-socket-mode)は、アプリに WebSocket での接続と、そのコネクション経由でのデータ受信を可能とします。Bolt for Python は、バージョン 1.2.0 からこれに対応しています。 + +ソケットモードでは、Slack からのペイロード送信を受け付けるエンドポイントをホストする HTTP サーバーを起動する代わりに WebSocket で Slack に接続し、そのコネクション経由でデータを受信します。ソケットモードを使う前に、アプリの管理画面でソケットモードの機能が有効になっていることを確認しておいてください。 + +ソケットモードを使用するには、環境変数に `SLACK_APP_TOKEN` を追加します。アプリのトークン(App-Level Token)は、アプリの設定の「**Basic Information**」セクションで確認できます。 + +[組み込みのソケットモードアダプター](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin)を使用するのがおすすめですが、サードパーティ製ライブラリを使ったアダプターの実装もいくつか存在しています。利用可能なアダプターの一覧です。 + +|内部的に利用する PyPI プロジェクト名|Bolt アダプター| +|-|-| +|[slack_sdk](https://pypi.org/project/slack-sdk/)|[slack_bolt.adapter.socket_mode](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin)| +|[websocket_client](https://pypi.org/project/websocket_client/)|[slack_bolt.adapter.socket_mode.websocket_client](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/websocket_client)| +|[aiohttp](https://pypi.org/project/aiohttp/) (asyncio-based)|[slack_bolt.adapter.socket_mode.aiohttp](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/aiohttp)| +|[websockets](https://pypi.org/project/websockets/) (asyncio-based)|[slack_bolt.adapter.socket_mode.websockets](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/websockets)| + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# 事前に Slack アプリをインストールし 'xoxb-' で始まるトークンを入手 +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + +# ここでミドルウェアとリスナーの追加を行います + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + handler.start() +``` + +## Async (asyncio) の利用 + +aiohttp のような asyncio をベースとしたアダプターを使う場合、アプリケーション全体が asyncio の async/await プログラミングモデルで実装されている必要があります。`AsyncApp` を動作させるためには `AsyncSocketModeHandler` とその async なミドルウェアやリスナーを利用します。 + +`AsyncApp` の使い方についての詳細は、[Async (asyncio) の利用](/tools/bolt-python/concepts/async)や、関連する[サンプルコード例](https://github.com/slackapi/bolt-python/tree/main/examples)を参考にしてください。 + +```python +from slack_bolt.app.async_app import AsyncApp +# デフォルトは aiohttp を使った実装 +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + +# ここでミドルウェアとリスナーの追加を行います + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/token-rotation.md b/docs/japanese/concepts/token-rotation.md new file mode 100644 index 000000000..25a0c735b --- /dev/null +++ b/docs/japanese/concepts/token-rotation.md @@ -0,0 +1,9 @@ +# トークンのローテーション + +Bolt for Python [v1.7.0](https://github.com/slackapi/bolt-python/releases/tag/v1.7.0) から、アクセストークンのさらなるセキュリティ強化のレイヤーであるトークンローテーションの機能に対応しています。トークンローテーションは [OAuth V2 の RFC](https://datatracker.ietf.org/doc/html/rfc6749#section-10.4) で規定されているものです。 + +既存の Slack アプリではアクセストークンが無期限に存在し続けるのに対して、トークンローテーションを有効にしたアプリではアクセストークンが失効するようになります。リフレッシュトークンを利用して、アクセストークンを長期間にわたって更新し続けることができます。 + +[Bolt for Python の組み込みの OAuth 機能](/tools/bolt-python/concepts/authenticating-oauth) を使用していれば、Bolt for Python が自動的にトークンローテーションの処理をハンドリングします。 + +トークンローテーションに関する詳細は [API ドキュメント](/authentication/using-token-rotation)を参照してください。 \ No newline at end of file diff --git a/docs/japanese/concepts/updating-pushing-views.md b/docs/japanese/concepts/updating-pushing-views.md new file mode 100644 index 000000000..2bbaf5ae5 --- /dev/null +++ b/docs/japanese/concepts/updating-pushing-views.md @@ -0,0 +1,48 @@ +# モーダルの更新と多重表示 + +モーダル内では、複数のモーダルをスタックのように重ねることができます。[`views_open`](/reference/methods/views.open/) という APIを呼び出すと、親となるとなるモーダルビューが追加されます。この最初の呼び出しの後、[`views_update`](/reference/methods/views.update/) を呼び出すことでそのビューを更新することができます。また、[`views_push`](/reference/methods/views.push) を呼び出すと、親のモーダルの上にさらに新しいモーダルビューを重ねることもできます。 + +**`views_update`** + +モーダルの更新は、組み込みのクライアントで `views_update` API を呼び出します。この API呼び出しでは、ビューを開いた時に生成された `view_id` と、更新後の `blocks` のリストを含む新しい `view` を指定します。既存のモーダルに含まれるエレメントをユーザーが操作した時にビューを更新する場合は、リクエストの `body` に含まれる `view_id` が利用できます。 + +**`views_push`** + +既存のモーダルの上に新しいモーダルをスタックのように追加する場合は、組み込みのクライアントで `views_push` API を呼び出します。この API 呼び出しでは、有効な `trigger_id` と新しい[ビューのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)を指定します。`views_push` の引数は [モーダルの開始](#creating-modals) と同じです。モーダルを開いた後、このモーダルのスタックに追加できるモーダルビューは 2 つまでです。 + +モーダルの更新と多重表示に関する詳細は、[API ドキュメント](/surfaces/modals)を参照してください。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +# モーダルに含まれる、`button_abc` という action_id のボタンの呼び出しをリッスン +@app.action("button_abc") +def update_modal(ack, body, client): + # ボタンのリクエストを確認 + ack() + # 組み込みのクライアントで views_update を呼び出し + client.views_update( + # view_id を渡すこと + view_id=body["view"]["id"], + # 競合状態を防ぐためのビューの状態を示す文字列 + hash=body["view"]["hash"], + # 更新後の blocks を含むビューのペイロード + view={ + "type": "modal", + # ビューの識別子 + "callback_id": "view_1", + "title": {"type": "plain_text", "text":"Updated modal"}, + "blocks": [ + { + "type": "section", + "text": {"type": "plain_text", "text":"You updated the modal!"} + }, + { + "type": "image", + "image_url": "https://media.giphy.com/media/SVZGEcYt7brkFUyU90/giphy.gif", + "alt_text":"Yay!The modal was updated" + } + ] + } + ) +``` \ No newline at end of file diff --git a/docs/japanese/concepts/view-submissions.md b/docs/japanese/concepts/view-submissions.md new file mode 100644 index 000000000..f82922004 --- /dev/null +++ b/docs/japanese/concepts/view-submissions.md @@ -0,0 +1,95 @@ +# モーダルの送信のリスニング + +[モーダルのペイロード](/reference/interaction-payloads/view-interactions-payload/#view_submission)に `input` ブロックを含める場合、その入力値を受け取るために`view_submission` リクエストをリッスンする必要があります。`view_submission` リクエストのリッスンには、組み込みの`view()` メソッドを利用することができます。`view()` の引数には、`str` 型または `re.Pattern` 型の `callback_id` を指定します。 + +`input` ブロックの値にアクセスするには `state` オブジェクトを参照します。`state` 内には `values` というオブジェクトがあり、`block_id` と一意の `action_id` に紐づける形で入力値を保持しています。 + +--- + +##### モーダル送信でのビューの更新 + +`view_submission` リクエストに対してモーダルを更新するには、リクエストの確認の中で `update` という `response_action` と新しく作成した `view` を指定します。 + +```python +# モーダル送信でのビューの更新 +@app.view("view_1") +def handle_submission(ack, body): + # build_new_view() method はモーダルビューを返します + # モーダルの構築には Block Kit Builder を試してみてください: + # https://app.slack.com/block-kit-builder/#%7B%22type%22:%22modal%22,%22callback_id%22:%22view_1%22,%22title%22:%7B%22type%22:%22plain_text%22,%22text%22:%22My%20App%22,%22emoji%22:true%7D,%22blocks%22:%5B%5D%7D + ack(response_action="update", view=build_new_view(body)) +``` +この例と同様に、モーダルでの送信リクエストに対して、[エラーを表示する](/surfaces/modals#displaying_errors)ためのオプションもあります。 + +モーダルの送信について詳しくは、[API ドキュメント](/surfaces/modals#interactions)を参照してください。 + +--- + +##### モーダルが閉じられたときの対応 + +`view_closed` リクエストをリッスンするためには `callback_id` を指定して、かつ `notify_on_close` 属性をモーダルのビューに設定する必要があります。以下のコード例をご覧ください。 + +よく詳しい情報は、[API ドキュメント](/surfaces/modals#interactions)を参照してください。 + +```python +client.views_open( + trigger_id=body.get("trigger_id"), + view={ + "type": "modal", + "callback_id": "modal-id", # view_closed の処理時に必要 + "title": { + "type": "plain_text", + "text": "Modal title" + }, + "blocks": [], + "close": { + "type": "plain_text", + "text": "Cancel" + }, + "notify_on_close": True, # この属性は必須 + } +) +# view_closed リクエストを処理する +@app.view_closed("modal-id") +def handle_view_closed(ack, body, logger): + ack() + logger.info(body) +``` + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 + +```python +# view_submission リクエストを処理 +@app.view("view_1") +def handle_submission(ack, body, client, view, logger): + # `input_c`という block_id に `dreamy_input` を持つ input ブロックがある場合 + hopes_and_dreams = view["state"]["values"]["input_c"]["dreamy_input"] + user = body["user"]["id"] + # 入力値を検証 + errors = {} + if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5: + errors["input_c"] = "The value must be longer than 5 characters" + if len(errors) > 0: + ack(response_action="errors", errors=errors) + return + # view_submission リクエストの確認を行い、モーダルを閉じる + ack() + # 入力されたデータを使った処理を実行。このサンプルでは DB に保存する処理を行う + # そして入力値の検証結果をユーザーに送信 + + # ユーザーに送信するメッセージ + msg = "" + try: + # DB に保存 + msg = f"Your submission of {hopes_and_dreams} was successful" + except Exception as e: + # エラーをハンドリング + msg = "There was an error with your submission" + + # ユーザーにメッセージを送信 + try: + client.chat_postMessage(channel=user, text=msg) + except e: + logger.exception(f"Failed to post a message {e}") + +``` \ No newline at end of file diff --git a/docs/japanese/concepts/web-api.md b/docs/japanese/concepts/web-api.md new file mode 100644 index 000000000..abb8e4121 --- /dev/null +++ b/docs/japanese/concepts/web-api.md @@ -0,0 +1,19 @@ +# Web API の使い方 + +`app.client`、またはミドルウェア・リスナーの引数 `client` として Bolt アプリに提供されている `WebClient` は必要な権限を付与されており、これを利用することで[あらゆる Web API メソッド](/reference/methods)を呼び出すことができます。このクライアントのメソッドを呼び出すと `SlackResponse` という Slack からの応答情報を含むオブジェクトが返されます。 + +Bolt の初期化に使用するトークンは `context` オブジェクトに設定されます。このトークンは、多くの Web API メソッドを呼び出す際に必要となります。 + +指定可能な引数の一覧は[モジュールドキュメント](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)を参考にしてください。 +```python +@app.message("wake me up") +def say_hello(client, message): + # 2020 年 9 月 30 日午後 11:59:59 を示す Unix エポック秒 + when_september_ends = 1601510399 + channel_id = message["channel"] + client.chat_scheduleMessage( + channel=channel_id, + post_at=when_september_ends, + text="Summer has come and passed" + ) +``` \ No newline at end of file diff --git a/docs/japanese/getting-started.md b/docs/japanese/getting-started.md new file mode 100644 index 000000000..46ac55ffb --- /dev/null +++ b/docs/japanese/getting-started.md @@ -0,0 +1,463 @@ +# Bolt 入門ガイド + +このガイドでは、Bolt for Python を使った Slack アプリの設定と起動の方法について説明します。ここで説明する手順では、まず新しい Slack アプリを作成し、ローカルの開発環境をセットアップし、Slack ワークスペースからのメッセージをリッスンして応答するアプリを開発するという流れになります。 + + +この手順を全て終わらせたら、あなたはきっと ⚡️[Slack アプリのはじめ方](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)のサンプルアプリを動作させたり、それに変更を加えたり、自分のアプリを作ったりすることができるようになるでしょう。 + +--- + +### アプリを作成する {#create-an-app} +最初にやるべきこと : Bolt での開発を始める前に、[Slack アプリを作成](https://api.slack.com/apps/new)します。 + +:::tip[通常の業務の妨げにならないよう、別の開発用のワークスペースを使用することをおすすめします。[新しいワークスペースは無料で作成できます](https://slack.com/get-started#create)] + +::: + +アプリ名を入力し(_後で変更可能_)、インストール先のワークスペースを選択して「`Create App`」ボタンをクリックすると、アプリの **Basic Information** ページが表示されます。 + +このページでは、アプリの概要や重要な認証情報を確認できます。これらの情報は後ほど参照します。 + +![Basic Information ページ](/img/bolt-python/basic-information-page.png "Basic Information ページ") + +ひと通り確認して、アプリのアイコンと説明を追加したら、アプリのプロジェクトの構成 🔩 を始めましょう。 + +--- + +### トークンとアプリのインストール {#tokens-and-installing-apps} +Slack アプリでは、[Slack API へのアクセスの管理に OAuth を使用します](/authentication/installing-with-oauth)。アプリがインストールされると、トークンが発行されます。アプリはそのトークンを使って API メソッドを呼び出すことができます。 + +Slack アプリで使用できるトークンには、ユーザートークン(`xoxp`)とボットトークン(`xoxb`)、アプリレベルトークン(`xapp`)の 3 種類があります。 +- [ユーザートークン](/authentication/tokens#user) を使用すると、アプリをインストールまたは認証したユーザーに成り代わって API メソッドを呼び出すことができます。1 つのワークスペースに複数のユーザートークンが存在する可能性があります。 +- [ボットトークン](/authentication/tokens#bot) はボットユーザーに関連づけられ、1 つのワークスペースでは最初に誰かがそのアプリをインストールした際に一度だけ発行されます。どのユーザーがインストールを実行しても、アプリが使用するボットトークンは同じになります。_ほとんど_のアプリで使用されるのは、ボットトークンです。 +- [アプリレベルトークン](/authentication/tokens#app-level) は、全ての組織(とその配下のワークスペースでの個々のユーザーによるインストール)を横断して、あなたのアプリを代理するものです。アプリレベルトークンは、アプリの WebSocket コネクションを確立するためによく使われます。 + +このガイドではボットトークンとアプリレベルトークンを使用します。 + +1. 左サイドバーの「**OAuth & Permissions**」をクリックし、「**Bot Token Scopes**」セクションまで下にスクロールします。「**Add an OAuth Scope**」をクリックします。 + +2. ここでは [`chat:write`](/reference/scopes/chat.write) というスコープのみを追加します。このスコープはアプリが参加しているチャンネルにメッセージを投稿することを許可します。 + +3. OAuth & Permissions ページの一番上までスクロールし、「**Install App to Workspace**」をクリックします。Slack の OAuth 確認画面 が表示されます。この画面で開発用ワークスペースへのアプリのインストールを承認します。 + +4. インストールを承認すると **OAuth & Permissions** ページが表示され、**Bot User OAuth Access Token** を確認できるでしょう。 + +![OAuth トークン](/img/bolt-python/bot-token.png "ボット用 OAuth トークン") + +5. 次に「**Basic Informationのページ**」まで戻り、アプリレベルトークンのセクションまで下にスクロールし「**Generate Token and Scopes**」をクリックしてアプリレベルトークンを作成します。このトークンに `connections:write` のスコープを付与し、作成された `xapp` トークンを保存します。これらのトークンは後ほど利用します。 + +6. 左サイドメニューの「**Socket Mode**」を有効にします。 + +:::tip[トークンはパスワードと同様に取り扱い、[安全な方法で保管してください](/concepts/security)。アプリはこのトークンを使って Slack ワークスペースで投稿をしたり、情報の取得をしたりします。] + +::: + +--- + +### プロジェクトをセットアップする {#setting-up-your-project} +初期設定が終わったら、新しい Bolt プロジェクトのセットアップを行いましょう。このプロジェクトが、あなたのアプリのロジックを処理するコードを配置する場所となります。 + +プロジェクトをまだ作成していない場合は、新しく作成しましょう。空のディレクトリを作成します。 + +```shell +mkdir first-bolt-app +cd first-bolt-app +``` + +次に、プロジェクトの依存ライブラリを管理する方法として、[Python 仮想環境](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment)を使ったおすすめの方法を紹介します。これはシステム Python に存在するパッケージとのコンフリクトを防ぐために推奨されている優れた方法です。[Python 3.7 以降](https://www.python.org/downloads/)の仮想環境を作成し、アクティブにしてみましょう。 + +```shell +python3 -m venv .venv +source .venv/bin/activate +``` + +`python3` へのパスがプロジェクトの中を指していることを確かめることで、仮想環境がアクティブになっていることを確認できます([Windows でもこれに似たコマンドが利用できます](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment))。 + +```shell +which python3 +# 出力結果: /path/to/first-bolt-app/.venv/bin/python3 +``` + +Bolt for Python のパッケージを新しいプロジェクトにインストールする前に、アプリの設定時に作成された **ボットトークン** と **アプリレベルトークン** を保存しましょう。 + +1. **OAuth & Permissions ページのボットトークン (xoxb) をコピー**して、新しい環境変数に保存します。以下のコマンド例は Linux と macOS で利用できます。[Windows でもこれに似たコマンドが利用できます](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153)。 +```shell +export SLACK_BOT_TOKEN=xoxb-<ボットトークン> +``` + +2. **Basic Information ページのアプリレベルトークン(xapp)をコピー**して、別の環境変数に保存します。 +```shell +export SLACK_APP_TOKEN=<アプリレベルトークン> +``` +:::warning[🔒 全てのトークンは安全に保管してください。] + +少なくともパブリックなバージョン管理にチェックインするようなことは避けるべきでしょう。また、上にあった例のように環境変数を介してアクセスするようにしてください。詳細な情報は [アプリのセキュリティのベストプラクティス](/concepts/security)のドキュメントを参照してください。 + +::: + +完了したら、いよいよアプリを作っていきましょう。以下のコマンドを使って、仮想環境に Python の `slack_bolt` パッケージをインストールします。 + +```shell +pip install slack_bolt +``` + +このディレクトリに「`app.py`」という名前の新しいファイルを作成し、以下のコードを追加します。 + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# ボットトークンとソケットモードハンドラーを使ってアプリを初期化します +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# アプリを起動します +if __name__ == "__main__": + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + +このようにトークンさえあれば、最初の Bolt アプリを作成することができます。「`app.py`」という名前でファイルを保存して、コマンドラインで以下を実行します。 + +```script +python3 app.py +``` + +アプリが起動し、実行中であることが表示されるはずです 🎉 + +--- + +### イベントを設定する {#setting-up-events} +アプリはワークスペース内の他のメンバーと同じように振る舞い、メッセージを投稿したり、絵文字リアクションを追加したり、イベントをリッスンして返答したりできます。 + +Slack ワークスペースで発生するイベント(メッセージが投稿されたときや、メッセージに対するリアクションがつけられたときなど)をリッスンするには、[Events API を使って特定の種類のイベントをサブスクライブします](/apis/events-api/)。 + +このチュートリアルの序盤でソケットモードを有効にしました。ソケットモードを使うことで、アプリが公開された HTTP エンドポイントを公開せずに Events API やインタラクティブコンポーネントを利用できるようになります。このことは、開発時やファイヤーウォールの裏からのリクエストを受ける際に便利です。HTTP での方式は、ホスティング環境にデプロイするアプリや Slack App Directory で配布されるアプリの開発・運用に適しています。 + +それでは、このアプリがどのイベントをリッスンしたいかを Slack に伝えましょう。 + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +1. アプリの構成ページに移動します ([アプリ設定ページから](https://api.slack.com/apps) アプリをクリックします)。左側のメニューで **ソケット モード** に移動し、有効に切り替えます。 + +2. [**基本情報**] に移動し、[アプリレベル トークン] セクションの下にスクロールして、[**トークンとスコープの生成**] をクリックしてアプリ トークンを生成します。このトークンに `connections:write` スコープを追加し、生成された `xapp` トークンを保存します。これはすぐに使用します。 + +3. 最後に、聞きたいイベントを Slack に伝えます。 **イベント サブスクリプション** で、**イベントを有効にする** というラベルのスイッチを切り替えます。 + +イベントが発生すると、そのイベントをトリガーしたユーザーやイベントが発生したチャンネルなど、イベントに関する情報が Slack からアプリに送信されます。アプリではこれらの情報を処理して、適切な応答を返します。 + + + + +1. アプリ構成ページに戻ります ([アプリ管理ページから](https://api.slack.com/apps) アプリをクリックします)。左側のサイドバーで [**イベント サブスクリプション**] をクリックします。 **イベントを有効にする**というラベルの付いたスイッチを切り替えます。 + +2. リクエスト URL を追加します。 Slack は、イベントに対応する HTTP POST リクエストをこの [リクエスト URL](/apis/events-api/#subscribing) に送信します。 Bolt は、`/slack/events` パスを使用して、すべての受信リクエスト (ショートカット、イベント、対話性ペイロードなど) をリッスンします。アプリ構成内でリクエスト URL を構成する場合は、`/slack/events` を追加します。 「https://あなたのドメイン/slack/events」。 💡 Bolt アプリが実行されている限り、URL は検証されるはずです。 + +:::tip + +ローカル開発の場合、ngrok などのプロキシ サービスを使用してパブリック URL を作成し、リクエストを開発環境にトンネリングできます。このトンネルの作成方法については、[ngrok のスタート ガイド](https://ngrok.com/docs#getting-started-expose) を参照してください。アプリをホスティングする際には、Slack 開発者がアプリをホストするために使用する最も一般的なホスティング プロバイダーを [API サイト](/app-management/hosting-slack-apps) に集めました。 + +::: + + + + +左側のサイドバーから **Event Subscriptions** にアクセスして、機能を有効にしてください。 **Subscribe to Bot Events** 配下で、ボットが受け取れるイベントを追加することができます。4つのメッセージに関するイベントがあります。 +- [`message.channels`](/reference/events/message.channels) アプリが参加しているパブリックチャンネルのメッセージをリッスン +- [`message.groups`](/reference/events/message.groups) アプリが参加しているプライベートチャンネルのメッセージをリッスン +- [`message.im`](/reference/events/message.im) あなたのアプリとユーザーのダイレクトメッセージをリッスン +- [`message.mpim`](/reference/events/message.mpim) あなたのアプリが追加されているグループ DM をリッスン + +ボットが参加するすべての場所のメッセージをリッスンさせるには、これら 4 つのメッセージイベントをすべて選択します。ボットにリッスンさせるメッセージイベントの種類を選択したら、「**Save Changes**」ボタンをクリックします。 + +--- + +### メッセージをリッスンして応答する {#listening-and-responding-to-a-message} +アプリにロジックを組み込む準備が整いました。まずは `message()` メソッドを使用して、メッセージのリスナーをアタッチしましょう。 + +次の例では、アプリが参加するチャンネルとダイレクトメッセージに投稿されるすべてのメッセージをリッスンし、「こんにちは」というメッセージに応答を返します。 + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# ボットトークンを渡してアプリを初期化します +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# 'こんにちは' を含むメッセージをリッスンします +# 指定可能なリスナーのメソッド引数の一覧は以下のモジュールドキュメントを参考にしてください: +# https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html +@app.message("こんにちは") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say(f"こんにちは、<@{message['user']}> さん!") + +if __name__ == "__main__": + # アプリを起動して、ソケットモードで Slack に接続します + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + + + + +```python +import os +from slack_bolt import App + +# ボットトークンと署名シークレットを使ってアプリを初期化します +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# 'hello' を含むメッセージをリッスンします +# 指定可能なリスナーのメソッド引数の一覧は以下のモジュールドキュメントを参考にしてください: +# https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html +@app.message("hello") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say(f"Hey there <@{message['user']}>!") + +# アプリを起動します +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +アプリを再起動して、ボットユーザーが参加しているチャンネル・ダイレクトメッセージに「こんにちは」というメッセージを投稿すると、アプリが返答を返すでしょう。 + +これはごく基本的なコード例ですが、最終的にやりたいことを実現するためにアプリをカスタマイズしていく土台として利用できます。次は、シンプルなテキストの返答を送信する代わりにメッセージ内にボタンを表示するという、もう少しインタラクティブな動作を試してみましょう。 + +--- + +### アクションを送信して応答する {#sending-and-responding-to-actions} + +インタラクティブ機能を有効にすると、ボタン、選択メニュー、日付ピッカー、モーダル、ショートカットなどの機能が利用できるようになります。アプリ設定ページの「**Interactivity & Shortcuts**」にアクセスしてください。 + + + + +ソケット モードをオンにすると、基本的な対話機能がデフォルトで有効になるため、それ以上の操作は必要ありません。 + + + + +イベントと同様に、Slack がアクション (*ユーザーがボタンをクリックした* など) を送信するには、URL を指定する必要があります。アプリ構成ページに戻り、左側にある **対話性とショートカット** をクリックします。別の **リクエスト URL** ボックスがあることがわかります。 + +:::tip + +ソケットモードを有効にしているとき、デフォルトで基本的なインタラクティブ機能が有効になっていることに気づくでしょう。追加のアクションは不要です。もし HTTP を使っている場合、Slack からのイベント送信先である Request URL を設定する必要があります。 + +::: + + + + +インタラクティビティが有効化されていれば、ショートカット、モーダル、インタラクティブコンポーネント (例:ボタン、選択メニュー、日付ピッカー) とのインタラクションはイベントとしてあなたのアプリに送信されます。 + +それでは、アプリのコードに戻り、これらのイベントを処理する為のロジックを追加しましょう。 +- まず、インタラクティブコンポーネント(ここではボタン)を含んだメッセージをアプリから送信します。 +- 次に、ユーザーから返されるボタンクリックのアクションをリッスンし、それに応答します。 + +以下のコードの後の部分を編集し、文字列だけのメッセージの代わりに、ボタンを含んだメッセージを送信するようにしてみます。 + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# ボットトークンを渡してアプリを初期化します +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# 'こんにちは' を含むメッセージをリッスンします +@app.message("こんにちは") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"こんにちは、<@{message['user']}> さん!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "クリックしてください"}, + "action_id": "button_click" + } + } + ], + text=f"こんにちは、<@{message['user']}> さん!", + ) + +if __name__ == "__main__": + # アプリを起動して、ソケットモードで Slack に接続します + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + + + + +```python +import os +from slack_bolt import App + +# ボットトークンと署名シークレットを使ってアプリを初期化します +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# 'hello' を含むメッセージをリッスンします +@app.message("hello") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text":"Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +# アプリを起動します +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +`say()` の中の値を `blocks` という配列のオブジェクトに変えました。ブロックは Slack メッセージを構成するコンポーネントであり、テキストや画像、日付ピッカーなど、さまざまなタイプのブロックがあります。この例では `accessory` に `button` を持たせた「section」のブロックを、アプリからの応答に含めています。`blocks` を使用する場合、`text` は通知やアクセシビリティのためのフォールバックとなります。 + +ボタンを含む `accessory` オブジェクトでは、`action_id` を指定していることがわかります。これは、ボタンを一意に示す識別子として機能します。これを使って、アプリをどのアクションに応答させるかを指定できます。 + +:::tip[[Block Kit Builder](https://app.slack.com/block-kit-builder) を使用すると、インタラクティブなメッセージのプロトタイプを簡単に作成できます。] + +自分自身やチームメンバーがメッセージのモックアップを作成し、生成される JSON をアプリに直接貼りつけることができます。 + +::: + +アプリを再起動し、アプリが参加しているチャンネルで「こんにちは」と入力すると、ボタン付きのメッセージが表示されるようになりました。ただし、ボタンをクリックしても、*まだ* 何も起こりません。 + +ハンドラーを追加して、ボタンがクリックされたときにフォローアップメッセージを送信するようにしてみましょう。 + + + + +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# ボットトークンを渡してアプリを初期化します +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# 'こんにちは' を含むメッセージをリッスンします +@app.message("こんにちは") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"こんにちは、<@{message['user']}> さん!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "クリックしてください"}, + "action_id": "button_click" + } + } + ], + text=f"こんにちは、<@{message['user']}> さん!", + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # アクションを確認したことを即時で応答します + ack() + # チャンネルにメッセージを投稿します + say(f"<@{body['user']['id']}> さんがボタンをクリックしました!") + +if __name__ == "__main__": + # アプリを起動して、ソケットモードで Slack に接続します + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + + + + +```python +import os +from slack_bolt import App + +# ボットトークンと署名シークレットを使ってアプリを初期化します +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +# 'hello' を含むメッセージをリッスンします +@app.message("hello") +def message_hello(message, say): + # イベントがトリガーされたチャンネルへ say() でメッセージを送信します + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text":"Click Me"}, + "action_id": "button_click" + } + } + ], + text=f"Hey there <@{message['user']}>!" + ) + +@app.action("button_click") +def action_button_click(body, ack, say): + # アクションを確認したことを即時で応答します + ack() + # チャンネルにメッセージを投稿します + say(f"<@{body['user']['id']}> clicked the button") + +# アプリを起動します +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) +``` + + + + +`app.action()` を使って、先ほど命名した `button_click` という `action_id` をリッスンしています。アプリを再起動し、ボタンをクリックすると、アプリからの「クリックしました!」というメッセージが新たに表示されるでしょう。 + +--- + +### 次のステップ {#next-steps} +はじめての [Bolt for Python アプリ](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started)を構築することができました。🎉 + +ここまでで基本的なアプリをセットアップして実行することはできたので、次は自分だけの Bolt アプリを作る方法について調べてみてください。参考になりそうなリソースをいくつかご紹介します。 + +* 基本的な概念について読んでみてください。Bolt アプリがアクセスできるさまざまメソッドや機能について知ることができます。 +* [`app.event()` メソッド](/tools/bolt-python/concepts/event-listening)でボットがリッスンできるイベントをほかにも試してみましょう。すべてのイベントの一覧は [API サイト](/reference/events)で確認できます。 +* Bolt では、アプリにアタッチされたクライアントから [Web API メソッドを呼び出す](/tools/bolt-python/concepts/web-api)ことができます。API サイトに [220 以上のメソッド](/reference/methods)を一覧しています。 +* [API サイト](/authentication/tokens)でほかのタイプのトークンを確認してみてください。アプリで実行したいアクションによって、異なるトークンが必要になる場合があります。 diff --git a/docs/japanese/legacy/steps-from-apps.md b/docs/japanese/legacy/steps-from-apps.md new file mode 100644 index 000000000..802802ab3 --- /dev/null +++ b/docs/japanese/legacy/steps-from-apps.md @@ -0,0 +1,184 @@ +# ワークフローステップの概要 + +(アプリによる)ワークフローステップでは、処理をアプリ側で行うカスタムのワークフローステップを提供することができます。ユーザーは[ワークフロービルダー](/workflows/workflow-builder)を使ってこれらのステップをワークフローに追加できます。 + +ワークフローステップは、次の 3 つのユーザーイベントで構成されます。 + +- ワークフローステップをワークフローに追加・変更する +- ワークフロー内のステップの設定内容を更新する +- エンドユーザーがそのステップを実行する + +ワークフローステップを機能させるためには、これら 3 つのイベントすべてに対応する必要があります。 + +## ステップの定義 + +ワークフローステップの作成には、Bolt が提供する `WorkflowStep` クラスを利用します。 + +ステップの `callback_id` と設定オブジェクトを指定して、`WorkflowStep` の新しいインスタンスを作成します。 + +設定オブジェクトは、`edit`、`save`、`execute` という 3 つのキーを持ちます。それぞれのキーは、単一のコールバック、またはコールバックのリストである必要があります。すべてのコールバックは、ワークフローステップのイベントに関する情報を保持する `step` オブジェクトにアクセスできます。 + +`WorkflowStep` のインスタンスを作成したら、それを`app.step()` メソッドに渡します。これによって、アプリがワークフローステップのイベントをリッスンし、設定オブジェクトで指定されたコールバックを使ってそれに応答できるようになります。 + +また、デコレーターとして利用できる `WorkflowStepBuilder` クラスを使ってワークフローステップを定義することもできます。 詳細は、[こちらのドキュメント](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/step.html#slack_bolt.workflows.step.step.WorkflowStepBuilder)のコード例などを参考にしてください。 + +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) + +```python +import os +from slack_bolt import App +from slack_bolt.workflows.step import WorkflowStep + +# いつも通りBolt アプリを起動する +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") +) + +def edit(ack, step, configure): + pass + +def save(ack, view, update): + pass + +def execute(step, complete, fail): + pass + +# WorkflowStep の新しいインスタンスを作成する +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +# ワークフローステップを渡してリスナーを設定する +app.step(ws) +``` + +## ステップの追加・編集 + +作成したワークフローステップがワークフローに追加またはその設定を変更されるタイミングで、`workflow_step_edit` イベントがアプリに送信されます。このイベントがアプリに届くと、`WorkflowStep` で設定した `edit` コールバックが実行されます。 + +ステップの追加と編集のどちらが行われるときも、ワークフローステップの設定モーダルをビルダーに送信する必要があります。このモーダルは、そのステップ独自の設定を選択するための場所です。通常のモーダルより制限が強く、例えば `title`、`submit`、`close` のプロパティを含めることができません。設定モーダルの `callback_id` は、デフォルトではワークフローステップと同じものになります。 + +`edit` コールバック内で `configure()` ユーティリティを使用すると、対応する `blocks` 引数にビューのblocks 部分だけを渡して、ステップの設定モーダルを簡単に表示させることができます。必要な入力内容が揃うまで設定の保存を無効にするには、`True` の値をセットした `submit_disabled` を渡します。 + +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) + +```python +def edit(ack, step, configure): + ack() + + blocks = [ + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "name", + "placeholder": {"type": "plain_text", "text":"Add a task name"}, + }, + "label": {"type": "plain_text", "text":"Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "description", + "placeholder": {"type": "plain_text", "text":"Add a task description"}, + }, + "label": {"type": "plain_text", "text":"Task description"}, + }, + ] + configure(blocks=blocks) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` + +## ステップの設定の保存 + +設定モーダルを開いた後、アプリは `view_submission` イベントをリッスンします。このイベントがアプリに届くと、`WorkflowStep` で設定した `save` コールバックが実行されます。 + +`save` コールバック内では、`update()` メソッドを使って、ワークフローに追加されたステップの設定を保存することができます。このメソッドには次の引数を指定します。 + +- `inputs` : ユーザーがワークフローステップを実行したときにアプリが受け取る予定のデータを表す辞書型の値です。 +- `outputs` : ワークフローステップの完了時にアプリが出力するデータが設定されたオブジェクトのリストです。この outputs は、ワークフローの後続のステップで利用することができます。 +- `step_name` : ステップのデフォルトの名前をオーバーライドします。 +- `step_image_url` : ステップのデフォルトの画像をオーバーライドします。 + +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) + +```python +def save(ack, view, update): + ack() + + values = view["state"]["values"] + task_name = values["task_name_input"]["name"] + task_description = values["task_description_input"]["description"] + + inputs = { + "task_name": {"value": task_name["value"]}, + "task_description": {"value": task_description["value"]} + } + outputs = [ + { + "type": "text", + "name": "task_name", + "label":"Task name", + }, + { + "type": "text", + "name": "task_description", + "label":"Task description", + } + ] + update(inputs=inputs, outputs=outputs) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` + +## ステップの実行 + +エンドユーザーがワークフローステップを実行すると、アプリに `workflow_step_execute` イベントが送信されます。このイベントがアプリに届くと、`WorkflowStep` で設定した `execute` コールバックが実行されます。 + +`save` コールバックで取り出した `inputs` を使って、サードパーティの API を呼び出す、情報をデータベースに保存する、ユーザーのホームタブを更新するといった処理を実行することができます。また、ワークフローの後続のステップで利用する出力値を `outputs` オブジェクトに設定します。 + +`execute` コールバック内では、`complete()` を呼び出してステップの実行が成功したことを示すか、`fail()` を呼び出してステップの実行が失敗したことを示す必要があります。 + +指定可能な引数の一覧はモジュールドキュメントを参考にしてください([共通](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) / [ステップ用](https://docs.slack.dev/tools/bolt-python/reference/workflows/step/utilities/index.html)) + +```python +def execute(step, complete, fail): + inputs = step["inputs"] + # すべての処理が成功した場合 + outputs = { + "task_name": inputs["task_name"]["value"], + "task_description": inputs["task_description"]["value"], + } + complete(outputs=outputs) + + # 失敗した処理がある場合 + error = {"message":"Just testing step failure!"} + fail(error=error) + +ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, +) +app.step(ws) +``` \ No newline at end of file diff --git a/docs/reference/adapter/aiohttp/index.html b/docs/reference/adapter/aiohttp/index.html new file mode 100644 index 000000000..7d7ceedbe --- /dev/null +++ b/docs/reference/adapter/aiohttp/index.html @@ -0,0 +1,128 @@ + + + + + + +slack_bolt.adapter.aiohttp API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aiohttp

+
+
+
+
+
+
+
+
+

Functions

+
+
+async def to_aiohttp_response(bolt_resp: BoltResponse) ‑> aiohttp.web_response.Response +
+
+
+ +Expand source code + +
async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response:
+    content_type = bolt_resp.headers.pop(
+        "content-type",
+        ["application/json" if bolt_resp.body.startswith("{") else "text/plain"],
+    )[0]
+    content_type = re.sub(r";\s*charset=utf-8", "", content_type)
+    resp = web.Response(
+        status=bolt_resp.status,
+        body=bolt_resp.body,
+        headers=bolt_resp.first_headers_without_set_cookie(),
+        content_type=content_type,
+    )
+    for cookie in bolt_resp.cookies():
+        for name, c in cookie.items():
+            resp.set_cookie(
+                name=name,
+                value=c.value,
+                max_age=c.get("max-age"),
+                expires=c.get("expires"),
+                path=c.get("path"),  # type: ignore[arg-type]
+                domain=c.get("domain"),
+                secure=True,
+                httponly=True,
+            )
+    return resp
+
+
+
+
+async def to_bolt_request(request: aiohttp.web_request.Request) ‑> AsyncBoltRequest +
+
+
+ +Expand source code + +
async def to_bolt_request(request: web.Request) -> AsyncBoltRequest:
+    return AsyncBoltRequest(
+        body=await request.text(),
+        query=request.query_string,
+        headers=request.headers,  # type: ignore[arg-type]
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/aiohttp/index.html b/docs/reference/adapter/asgi/aiohttp/index.html new file mode 100644 index 000000000..a6aa7c92d --- /dev/null +++ b/docs/reference/adapter/asgi/aiohttp/index.html @@ -0,0 +1,164 @@ + + + + + + +slack_bolt.adapter.asgi.aiohttp API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi.aiohttp

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackRequestHandler +(app: AsyncApp,
path: str = '/slack/events')
+
+
+
+ +Expand source code + +
class AsyncSlackRequestHandler(SlackRequestHandler):
+    app: AsyncApp
+
+    def __init__(self, app: AsyncApp, path: str = "/slack/events"):
+        """Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers.
+        This can be used for production deployment.
+
+        With the default settings, `http://localhost:3000/slack/events`
+        Run Bolt with [uvicron](https://www.uvicorn.org/)
+
+            # Python
+            app = AsyncApp()
+            api = SlackRequestHandler(app)
+
+            # bash
+            export SLACK_SIGNING_SECRET=***
+            export SLACK_BOT_TOKEN=xoxb-***
+            uvicorn app:api --port 3000 --log-level debug
+
+        Args:
+            app: Your bolt application
+            path: The path to handle request from Slack (Default: `/slack/events`)
+        """
+        self.path = path
+        self.app = app
+
+    async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
+        return await self.app.async_dispatch(
+            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
+        return await self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
+        return await self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+

Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

+

With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

+
# Python
+app = AsyncApp()
+api = SlackRequestHandler(app)
+
+# bash
+export SLACK_SIGNING_SECRET=***
+export SLACK_BOT_TOKEN=xoxb-***
+uvicorn app:api --port 3000 --log-level debug
+
+

Args

+
+
app
+
Your bolt application
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/async_handler.html b/docs/reference/adapter/asgi/async_handler.html new file mode 100644 index 000000000..23433ffce --- /dev/null +++ b/docs/reference/adapter/asgi/async_handler.html @@ -0,0 +1,164 @@ + + + + + + +slack_bolt.adapter.asgi.async_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi.async_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackRequestHandler +(app: AsyncApp,
path: str = '/slack/events')
+
+
+
+ +Expand source code + +
class AsyncSlackRequestHandler(SlackRequestHandler):
+    app: AsyncApp
+
+    def __init__(self, app: AsyncApp, path: str = "/slack/events"):
+        """Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers.
+        This can be used for production deployment.
+
+        With the default settings, `http://localhost:3000/slack/events`
+        Run Bolt with [uvicron](https://www.uvicorn.org/)
+
+            # Python
+            app = AsyncApp()
+            api = SlackRequestHandler(app)
+
+            # bash
+            export SLACK_SIGNING_SECRET=***
+            export SLACK_BOT_TOKEN=xoxb-***
+            uvicorn app:api --port 3000 --log-level debug
+
+        Args:
+            app: Your bolt application
+            path: The path to handle request from Slack (Default: `/slack/events`)
+        """
+        self.path = path
+        self.app = app
+
+    async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
+        return await self.app.async_dispatch(
+            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
+        return await self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
+        return await self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+            AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+

Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

+

With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

+
# Python
+app = AsyncApp()
+api = SlackRequestHandler(app)
+
+# bash
+export SLACK_SIGNING_SECRET=***
+export SLACK_BOT_TOKEN=xoxb-***
+uvicorn app:api --port 3000 --log-level debug
+
+

Args

+
+
app
+
Your bolt application
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/base_handler.html b/docs/reference/adapter/asgi/base_handler.html new file mode 100644 index 000000000..74358683e --- /dev/null +++ b/docs/reference/adapter/asgi/base_handler.html @@ -0,0 +1,208 @@ + + + + + + +slack_bolt.adapter.asgi.base_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi.base_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BaseSlackRequestHandler +
+
+
+ +Expand source code + +
class BaseSlackRequestHandler:
+    app: Union[App, "AsyncApp"]  # type: ignore[name-defined]
+    path: str
+
+    async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
+        """Dispatches a request to the Bolt App"""
+        raise NotImplementedError
+
+    async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
+        """Handles installation of the OAuthFlow"""
+        raise NotImplementedError
+
+    async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
+        """Handles the callback of the OAuthFlow"""
+        raise NotImplementedError
+
+    async def _get_http_response(self, method: str, path: str, request: AsgiHttpRequest) -> AsgiHttpResponse:
+        if method == "GET":
+            if self.app.oauth_flow is not None:
+                if path == self.app.oauth_flow.install_path:
+                    bolt_response: BoltResponse = await self.handle_installation(request)
+                    return AsgiHttpResponse(
+                        status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
+                    )
+                elif path == self.app.oauth_flow.redirect_uri_path:
+                    bolt_response = await self.handle_callback(request)
+                    return AsgiHttpResponse(
+                        status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
+                    )
+        if method == "POST" and path == self.path:
+            bolt_response = await self.dispatch(request)
+            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, 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":
+            response: AsgiHttpResponse = await self._get_http_response(
+                method=scope["method"], path=scope["path"], request=AsgiHttpRequest(scope, receive)  # type: ignore[arg-type]
+            )
+            await send(response.get_response_start())
+            await send(response.get_response_body())
+            return
+        if scope["type"] == "lifespan":
+            await self._handle_lifespan(receive, send)
+            return
+        raise TypeError(f"Unsupported scope type: {scope['type']!r}")
+
+
+

Subclasses

+ +

Class variables

+
+
var appApp | AsyncApp
+
+

The type of the None singleton.

+
+
var path : str
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def dispatch(self,
request: AsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
+    """Dispatches a request to the Bolt App"""
+    raise NotImplementedError
+
+

Dispatches a request to the Bolt App

+
+
+async def handle_callback(self,
request: AsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
+    """Handles the callback of the OAuthFlow"""
+    raise NotImplementedError
+
+

Handles the callback of the OAuthFlow

+
+
+async def handle_installation(self,
request: AsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
+    """Handles installation of the OAuthFlow"""
+    raise NotImplementedError
+
+

Handles installation of the OAuthFlow

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/builtin/index.html b/docs/reference/adapter/asgi/builtin/index.html new file mode 100644 index 000000000..9147380c5 --- /dev/null +++ b/docs/reference/adapter/asgi/builtin/index.html @@ -0,0 +1,165 @@ + + + + + + +slack_bolt.adapter.asgi.builtin API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi.builtin

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App,
path: str = '/slack/events')
+
+
+
+ +Expand source code + +
class SlackRequestHandler(BaseSlackRequestHandler):
+    def __init__(self, app: App, path: str = "/slack/events"):
+        """Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers.
+        This can be used for production deployment.
+
+        With the default settings, `http://localhost:3000/slack/events`
+        Run Bolt with [uvicron](https://www.uvicorn.org/)
+
+            # Python
+            app = App()
+            api = SlackRequestHandler(app)
+
+            # bash
+            export SLACK_SIGNING_SECRET=***
+            export SLACK_BOT_TOKEN=xoxb-***
+            uvicorn app:api --port 3000 --log-level debug
+
+        Args:
+            app: Your bolt application
+            path: The path to handle request from Slack (Default: `/slack/events`)
+        """
+        self.path = path
+        self.app = app
+
+    async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
+        return self.app.dispatch(
+            BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+            BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+            BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+

Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

+

With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

+
# Python
+app = App()
+api = SlackRequestHandler(app)
+
+# bash
+export SLACK_SIGNING_SECRET=***
+export SLACK_BOT_TOKEN=xoxb-***
+uvicorn app:api --port 3000 --log-level debug
+
+

Args

+
+
app
+
Your bolt application
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/http_request.html b/docs/reference/adapter/asgi/http_request.html new file mode 100644 index 000000000..062ac7ca2 --- /dev/null +++ b/docs/reference/adapter/asgi/http_request.html @@ -0,0 +1,256 @@ + + + + + + +slack_bolt.adapter.asgi.http_request API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi.http_request

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsgiHttpRequest +(scope: Dict[str, str | bytes | Iterable[Tuple[bytes, bytes]]],
receive: Callable)
+
+
+
+ +Expand source code + +
class AsgiHttpRequest:
+    __slots__ = ("receive", "query_string", "raw_headers")
+
+    def __init__(self, scope: scope_type, receive: Callable):
+        self.receive = receive
+        self.query_string = str(scope["query_string"], ENCODING)  # type: ignore[arg-type]
+        self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"]  # type: ignore[assignment]
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
+
+    async def get_raw_body(self) -> str:
+        chunks = bytearray()
+        while True:
+            chunk: Dict[str, Union[str, bytes]] = await self.receive()
+
+            if chunk["type"] != "http.request":
+                raise Exception("Body chunks could not be received from asgi server")
+
+            chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
+            if not chunk.get("more_body", False):
+                break
+        return bytes(chunks).decode(ENCODING)
+
+
+

Instance variables

+
+
var query_string
+
+
+ +Expand source code + +
class AsgiHttpRequest:
+    __slots__ = ("receive", "query_string", "raw_headers")
+
+    def __init__(self, scope: scope_type, receive: Callable):
+        self.receive = receive
+        self.query_string = str(scope["query_string"], ENCODING)  # type: ignore[arg-type]
+        self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"]  # type: ignore[assignment]
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
+
+    async def get_raw_body(self) -> str:
+        chunks = bytearray()
+        while True:
+            chunk: Dict[str, Union[str, bytes]] = await self.receive()
+
+            if chunk["type"] != "http.request":
+                raise Exception("Body chunks could not be received from asgi server")
+
+            chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
+            if not chunk.get("more_body", False):
+                break
+        return bytes(chunks).decode(ENCODING)
+
+
+
+
var raw_headers
+
+
+ +Expand source code + +
class AsgiHttpRequest:
+    __slots__ = ("receive", "query_string", "raw_headers")
+
+    def __init__(self, scope: scope_type, receive: Callable):
+        self.receive = receive
+        self.query_string = str(scope["query_string"], ENCODING)  # type: ignore[arg-type]
+        self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"]  # type: ignore[assignment]
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
+
+    async def get_raw_body(self) -> str:
+        chunks = bytearray()
+        while True:
+            chunk: Dict[str, Union[str, bytes]] = await self.receive()
+
+            if chunk["type"] != "http.request":
+                raise Exception("Body chunks could not be received from asgi server")
+
+            chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
+            if not chunk.get("more_body", False):
+                break
+        return bytes(chunks).decode(ENCODING)
+
+
+
+
var receive
+
+
+ +Expand source code + +
class AsgiHttpRequest:
+    __slots__ = ("receive", "query_string", "raw_headers")
+
+    def __init__(self, scope: scope_type, receive: Callable):
+        self.receive = receive
+        self.query_string = str(scope["query_string"], ENCODING)  # type: ignore[arg-type]
+        self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"]  # type: ignore[assignment]
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
+
+    async def get_raw_body(self) -> str:
+        chunks = bytearray()
+        while True:
+            chunk: Dict[str, Union[str, bytes]] = await self.receive()
+
+            if chunk["type"] != "http.request":
+                raise Exception("Body chunks could not be received from asgi server")
+
+            chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
+            if not chunk.get("more_body", False):
+                break
+        return bytes(chunks).decode(ENCODING)
+
+
+
+
+

Methods

+
+
+def get_headers(self) ‑> Dict[str, str | Sequence[str]] +
+
+
+ +Expand source code + +
def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+    return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers}
+
+
+
+
+async def get_raw_body(self) ‑> str +
+
+
+ +Expand source code + +
async def get_raw_body(self) -> str:
+    chunks = bytearray()
+    while True:
+        chunk: Dict[str, Union[str, bytes]] = await self.receive()
+
+        if chunk["type"] != "http.request":
+            raise Exception("Body chunks could not be received from asgi server")
+
+        chunks.extend(chunk.get("body", b""))  # type: ignore[arg-type]
+        if not chunk.get("more_body", False):
+            break
+    return bytes(chunks).decode(ENCODING)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/http_response.html b/docs/reference/adapter/asgi/http_response.html new file mode 100644 index 000000000..0c42d9a9f --- /dev/null +++ b/docs/reference/adapter/asgi/http_response.html @@ -0,0 +1,270 @@ + + + + + + +slack_bolt.adapter.asgi.http_response API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi.http_response

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsgiHttpResponse +(status: int, headers: Dict[str, Sequence[str]] = {}, body: str = '') +
+
+
+ +Expand source code + +
class AsgiHttpResponse:
+    __slots__ = ("status", "raw_headers", "body")
+
+    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
+        self.status: int = status
+        self.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 {
+            "type": "http.response.start",
+            "status": self.status,
+            "headers": self.raw_headers,
+        }
+
+    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
+        return {
+            "type": "http.response.body",
+            "body": self.body,
+            "more_body": False,
+        }
+
+
+

Instance variables

+
+
var body
+
+
+ +Expand source code + +
class AsgiHttpResponse:
+    __slots__ = ("status", "raw_headers", "body")
+
+    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
+        self.status: int = status
+        self.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 {
+            "type": "http.response.start",
+            "status": self.status,
+            "headers": self.raw_headers,
+        }
+
+    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
+        return {
+            "type": "http.response.body",
+            "body": self.body,
+            "more_body": False,
+        }
+
+
+
+
var raw_headers
+
+
+ +Expand source code + +
class AsgiHttpResponse:
+    __slots__ = ("status", "raw_headers", "body")
+
+    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
+        self.status: int = status
+        self.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 {
+            "type": "http.response.start",
+            "status": self.status,
+            "headers": self.raw_headers,
+        }
+
+    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
+        return {
+            "type": "http.response.body",
+            "body": self.body,
+            "more_body": False,
+        }
+
+
+
+
var status
+
+
+ +Expand source code + +
class AsgiHttpResponse:
+    __slots__ = ("status", "raw_headers", "body")
+
+    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
+        self.status: int = status
+        self.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 {
+            "type": "http.response.start",
+            "status": self.status,
+            "headers": self.raw_headers,
+        }
+
+    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
+        return {
+            "type": "http.response.body",
+            "body": self.body,
+            "more_body": False,
+        }
+
+
+
+
+

Methods

+
+
+def get_response_body(self) ‑> Dict[str, str | bytes | bool] +
+
+
+ +Expand source code + +
def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
+    return {
+        "type": "http.response.body",
+        "body": self.body,
+        "more_body": False,
+    }
+
+
+
+
+def get_response_start(self) ‑> Dict[str, str | int | Iterable[Tuple[bytes, bytes]]] +
+
+
+ +Expand source code + +
def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
+    return {
+        "type": "http.response.start",
+        "status": self.status,
+        "headers": self.raw_headers,
+    }
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/index.html b/docs/reference/adapter/asgi/index.html new file mode 100644 index 000000000..0f2abec74 --- /dev/null +++ b/docs/reference/adapter/asgi/index.html @@ -0,0 +1,207 @@ + + + + + + +slack_bolt.adapter.asgi API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.asgi.aiohttp
+
+
+
+
slack_bolt.adapter.asgi.async_handler
+
+
+
+
slack_bolt.adapter.asgi.base_handler
+
+
+
+
slack_bolt.adapter.asgi.builtin
+
+
+
+
slack_bolt.adapter.asgi.http_request
+
+
+
+
slack_bolt.adapter.asgi.http_response
+
+
+
+
slack_bolt.adapter.asgi.utils
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App,
path: str = '/slack/events')
+
+
+
+ +Expand source code + +
class SlackRequestHandler(BaseSlackRequestHandler):
+    def __init__(self, app: App, path: str = "/slack/events"):
+        """Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers.
+        This can be used for production deployment.
+
+        With the default settings, `http://localhost:3000/slack/events`
+        Run Bolt with [uvicron](https://www.uvicorn.org/)
+
+            # Python
+            app = App()
+            api = SlackRequestHandler(app)
+
+            # bash
+            export SLACK_SIGNING_SECRET=***
+            export SLACK_BOT_TOKEN=xoxb-***
+            uvicorn app:api --port 3000 --log-level debug
+
+        Args:
+            app: Your bolt application
+            path: The path to handle request from Slack (Default: `/slack/events`)
+        """
+        self.path = path
+        self.app = app
+
+    async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse:
+        return self.app.dispatch(
+            BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+            BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+            BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+

Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. +This can be used for production deployment.

+

With the default settings, http://localhost:3000/slack/events +Run Bolt with uvicron

+
# Python
+app = App()
+api = SlackRequestHandler(app)
+
+# bash
+export SLACK_SIGNING_SECRET=***
+export SLACK_BOT_TOKEN=xoxb-***
+uvicorn app:api --port 3000 --log-level debug
+
+

Args

+
+
app
+
Your bolt application
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/asgi/utils.html b/docs/reference/adapter/asgi/utils.html new file mode 100644 index 000000000..8eb2a24f1 --- /dev/null +++ b/docs/reference/adapter/asgi/utils.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.adapter.asgi.utils API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.asgi.utils

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/chalice_handler.html b/docs/reference/adapter/aws_lambda/chalice_handler.html new file mode 100644 index 000000000..28c75ea6a --- /dev/null +++ b/docs/reference/adapter/aws_lambda/chalice_handler.html @@ -0,0 +1,284 @@ + + + + + + +slack_bolt.adapter.aws_lambda.chalice_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda.chalice_handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def not_found() ‑> chalice.app.Response +
+
+
+ +Expand source code + +
def not_found() -> Response:
+    return Response(
+        status_code=404,
+        body="Not Found",
+        headers={},
+    )
+
+
+
+
+def to_bolt_request(request: chalice.app.Request, body: str) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_bolt_request(request: Request, body: str) -> BoltRequest:
+    return BoltRequest(
+        body=body,
+        query=request.query_params,  # type: ignore[arg-type]
+        headers=request.headers,  # type: ignore[arg-type]
+    )
+
+
+
+
+def to_chalice_response(resp: BoltResponse) ‑> chalice.app.Response +
+
+
+ +Expand source code + +
def to_chalice_response(resp: BoltResponse) -> Response:
+    return Response(
+        status_code=resp.status,
+        body=resp.body,
+        headers=resp.first_headers(),  # type: ignore[arg-type]
+    )
+
+
+
+
+
+
+

Classes

+
+
+class ChaliceSlackRequestHandler +(app: App,
chalice: chalice.app.Chalice,
lambda_client: botocore.client.BaseClient | None = None)
+
+
+
+ +Expand source code + +
class ChaliceSlackRequestHandler:
+    def __init__(self, app: App, chalice: Chalice, lambda_client: Optional[BaseClient] = None):
+        self.app = app
+        self.chalice = chalice
+        self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler, app.logger)
+
+        if getenv("AWS_CHALICE_CLI_MODE") == "true" and lambda_client is None:
+            try:
+                from slack_bolt.adapter.aws_lambda.local_lambda_client import (
+                    LocalLambdaClient,
+                )
+
+                lambda_client = LocalLambdaClient(self.chalice, None)  # type: ignore[arg-type]
+            except ImportError:
+                logging.info("Failed to load LocalLambdaClient for CLI mode.")
+                pass
+
+        self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner(
+            logger=self.logger, lambda_client=lambda_client
+        )
+
+        if self.app.oauth_flow is not None:
+            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
+
+    @classmethod
+    def clear_all_log_handlers(cls):
+        # https://stackoverflow.com/questions/37703609/using-python-logging-with-aws-lambda
+        root = logging.getLogger()
+        if root.handlers:
+            for handler in root.handlers:
+                root.removeHandler(handler)
+
+    def handle(self, request: Request):
+        body: str = request.raw_body.decode("utf-8") if request.raw_body else ""  # type: ignore[union-attr]
+        self.logger.debug(f"Incoming request: {request.to_dict()}, body: {body}")
+
+        method = request.method
+        if method is None:
+            return not_found()
+        if method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                bolt_req: BoltRequest = to_bolt_request(request, body)
+                query = bolt_req.query
+                is_callback = query is not None and (
+                    (_first_value(query, "code") is not None and _first_value(query, "state") is not None)
+                    or _first_value(query, "error") is not None
+                )
+                if is_callback:
+                    bolt_resp = oauth_flow.handle_callback(bolt_req)
+                    return to_chalice_response(bolt_resp)
+                else:
+                    bolt_resp = oauth_flow.handle_installation(bolt_req)
+                    return to_chalice_response(bolt_resp)
+        elif method == "POST":
+            bolt_req = to_bolt_request(request, body)
+            # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
+            aws_lambda_function_name = self.chalice.lambda_context.function_name
+            bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
+            bolt_req.context["chalice_request"] = request.to_dict()
+            bolt_resp = self.app.dispatch(bolt_req)
+            aws_response = to_chalice_response(bolt_resp)
+            return aws_response
+        elif method == "NONE":
+            bolt_req = to_bolt_request(request, body)
+            bolt_resp = self.app.dispatch(bolt_req)
+            aws_response = to_chalice_response(bolt_resp)
+            return aws_response
+
+        return not_found()
+
+
+

Static methods

+
+
+def clear_all_log_handlers() +
+
+
+
+
+

Methods

+
+
+def handle(self, request: chalice.app.Request) +
+
+
+ +Expand source code + +
def handle(self, request: Request):
+    body: str = request.raw_body.decode("utf-8") if request.raw_body else ""  # type: ignore[union-attr]
+    self.logger.debug(f"Incoming request: {request.to_dict()}, body: {body}")
+
+    method = request.method
+    if method is None:
+        return not_found()
+    if method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            bolt_req: BoltRequest = to_bolt_request(request, body)
+            query = bolt_req.query
+            is_callback = query is not None and (
+                (_first_value(query, "code") is not None and _first_value(query, "state") is not None)
+                or _first_value(query, "error") is not None
+            )
+            if is_callback:
+                bolt_resp = oauth_flow.handle_callback(bolt_req)
+                return to_chalice_response(bolt_resp)
+            else:
+                bolt_resp = oauth_flow.handle_installation(bolt_req)
+                return to_chalice_response(bolt_resp)
+    elif method == "POST":
+        bolt_req = to_bolt_request(request, body)
+        # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
+        aws_lambda_function_name = self.chalice.lambda_context.function_name
+        bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
+        bolt_req.context["chalice_request"] = request.to_dict()
+        bolt_resp = self.app.dispatch(bolt_req)
+        aws_response = to_chalice_response(bolt_resp)
+        return aws_response
+    elif method == "NONE":
+        bolt_req = to_bolt_request(request, body)
+        bolt_resp = self.app.dispatch(bolt_req)
+        aws_response = to_chalice_response(bolt_resp)
+        return aws_response
+
+    return not_found()
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html b/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html new file mode 100644 index 000000000..f27e09c93 --- /dev/null +++ b/docs/reference/adapter/aws_lambda/chalice_lazy_listener_runner.html @@ -0,0 +1,130 @@ + + + + + + +slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class ChaliceLazyListenerRunner +(logger: logging.Logger,
lambda_client: botocore.client.BaseClient | None = None)
+
+
+
+ +Expand source code + +
class ChaliceLazyListenerRunner(LazyListenerRunner):
+    def __init__(self, logger: Logger, lambda_client: Optional[BaseClient] = None):
+        self.lambda_client = lambda_client
+        self.logger = logger
+
+    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+        if self.lambda_client is None:
+            self.lambda_client = boto3.client("lambda")
+
+        chalice_request: dict = request.context["chalice_request"]
+        request.headers["x-slack-bolt-lazy-only"] = ["1"]
+        request.headers["x-slack-bolt-lazy-function-name"] = [request.lazy_function_name]  # type: ignore[list-item]
+        payload = {
+            "method": "NONE",
+            "headers": {k: v[0] for k, v in request.headers.items()},
+            "multiValueQueryStringParameters": request.query,
+            "queryStringParameters": {k: v[0] for k, v in request.query.items()},
+            "pathParameters": {},
+            "stageVariables": {},
+            "requestContext": chalice_request["context"],
+            "body": request.raw_body,
+            "isBase64Encoded": False,
+        }
+        invocation = self.lambda_client.invoke(
+            FunctionName=request.context["aws_lambda_function_name"],
+            InvocationType="Event",
+            Payload=json.dumps(payload),
+        )
+
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/handler.html b/docs/reference/adapter/aws_lambda/handler.html new file mode 100644 index 000000000..08e4ac9b7 --- /dev/null +++ b/docs/reference/adapter/aws_lambda/handler.html @@ -0,0 +1,287 @@ + + + + + + +slack_bolt.adapter.aws_lambda.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def not_found() ‑> Dict[str, Any] +
+
+
+ +Expand source code + +
def not_found() -> Dict[str, Any]:
+    return {
+        "statusCode": 404,
+        "body": "Not Found",
+        "headers": {},
+    }
+
+
+
+
+def to_aws_response(resp: BoltResponse) ‑> Dict[str, Any] +
+
+
+ +Expand source code + +
def to_aws_response(resp: BoltResponse) -> Dict[str, Any]:
+    return {
+        "statusCode": resp.status,
+        "body": resp.body,
+        "headers": resp.first_headers(),
+    }
+
+
+
+
+def to_bolt_request(event) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_bolt_request(event) -> BoltRequest:
+    body = event.get("body", "")
+    if event["isBase64Encoded"]:
+        body = base64.b64decode(body).decode("utf-8")
+    cookies: Sequence[str] = event.get("cookies", [])
+    if cookies is None or len(cookies) == 0:
+        # In the case of format v1
+        multiValueHeaders = event.get("multiValueHeaders", {})
+        cookies = multiValueHeaders.get("cookie", [])
+        if len(cookies) == 0:
+            # Try using uppercase
+            cookies = multiValueHeaders.get("Cookie", [])
+    headers = event.get("headers", {})
+    headers["cookie"] = cookies
+    return BoltRequest(
+        body=body,
+        query=event.get("queryStringParameters", {}),
+        headers=headers,
+    )
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+        self.logger = get_bolt_app_logger(app.name, SlackRequestHandler, app.logger)
+        self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner(self.logger)
+        if self.app.oauth_flow is not None:
+            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
+
+    @classmethod
+    def clear_all_log_handlers(cls):
+        # https://stackoverflow.com/questions/37703609/using-python-logging-with-aws-lambda
+        root = logging.getLogger()
+        if root.handlers:
+            for handler in root.handlers:
+                root.removeHandler(handler)
+
+    def handle(self, event, context):
+        self.logger.debug(f"Incoming event: {event}, context: {context}")
+
+        method = event.get("requestContext", {}).get("http", {}).get("method")
+        if method is None:
+            method = event.get("requestContext", {}).get("httpMethod")
+
+        if method is None:
+            return not_found()
+        if method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                bolt_req: BoltRequest = to_bolt_request(event)
+                query = bolt_req.query
+                is_callback = query is not None and (
+                    (_first_value(query, "code") is not None and _first_value(query, "state") is not None)
+                    or _first_value(query, "error") is not None
+                )
+                if is_callback:
+                    bolt_resp = oauth_flow.handle_callback(bolt_req)
+                    return to_aws_response(bolt_resp)
+                else:
+                    bolt_resp = oauth_flow.handle_installation(bolt_req)
+                    return to_aws_response(bolt_resp)
+        elif method == "POST":
+            bolt_req = to_bolt_request(event)
+            # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
+            aws_lambda_function_name = context.function_name
+            bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
+            bolt_req.context["aws_lambda_invoked_function_arn"] = context.invoked_function_arn
+            bolt_req.context["lambda_request"] = event
+            bolt_resp = self.app.dispatch(bolt_req)
+            aws_response = to_aws_response(bolt_resp)
+            return aws_response
+        elif method == "NONE":
+            bolt_req = to_bolt_request(event)
+            bolt_resp = self.app.dispatch(bolt_req)
+            aws_response = to_aws_response(bolt_resp)
+            return aws_response
+
+        return not_found()
+
+
+

Static methods

+
+
+def clear_all_log_handlers() +
+
+
+
+
+

Methods

+
+
+def handle(self, event, context) +
+
+
+ +Expand source code + +
def handle(self, event, context):
+    self.logger.debug(f"Incoming event: {event}, context: {context}")
+
+    method = event.get("requestContext", {}).get("http", {}).get("method")
+    if method is None:
+        method = event.get("requestContext", {}).get("httpMethod")
+
+    if method is None:
+        return not_found()
+    if method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            bolt_req: BoltRequest = to_bolt_request(event)
+            query = bolt_req.query
+            is_callback = query is not None and (
+                (_first_value(query, "code") is not None and _first_value(query, "state") is not None)
+                or _first_value(query, "error") is not None
+            )
+            if is_callback:
+                bolt_resp = oauth_flow.handle_callback(bolt_req)
+                return to_aws_response(bolt_resp)
+            else:
+                bolt_resp = oauth_flow.handle_installation(bolt_req)
+                return to_aws_response(bolt_resp)
+    elif method == "POST":
+        bolt_req = to_bolt_request(event)
+        # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
+        aws_lambda_function_name = context.function_name
+        bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
+        bolt_req.context["aws_lambda_invoked_function_arn"] = context.invoked_function_arn
+        bolt_req.context["lambda_request"] = event
+        bolt_resp = self.app.dispatch(bolt_req)
+        aws_response = to_aws_response(bolt_resp)
+        return aws_response
+    elif method == "NONE":
+        bolt_req = to_bolt_request(event)
+        bolt_resp = self.app.dispatch(bolt_req)
+        aws_response = to_aws_response(bolt_resp)
+        return aws_response
+
+    return not_found()
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/index.html b/docs/reference/adapter/aws_lambda/index.html new file mode 100644 index 000000000..0aae2c31a --- /dev/null +++ b/docs/reference/adapter/aws_lambda/index.html @@ -0,0 +1,255 @@ + + + + + + +slack_bolt.adapter.aws_lambda API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.aws_lambda.chalice_handler
+
+
+
+
slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner
+
+
+
+
slack_bolt.adapter.aws_lambda.handler
+
+
+
+
slack_bolt.adapter.aws_lambda.internals
+
+
+
+
slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow
+
+
+
+
slack_bolt.adapter.aws_lambda.lazy_listener_runner
+
+
+
+
slack_bolt.adapter.aws_lambda.local_lambda_client
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+        self.logger = get_bolt_app_logger(app.name, SlackRequestHandler, app.logger)
+        self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner(self.logger)
+        if self.app.oauth_flow is not None:
+            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
+
+    @classmethod
+    def clear_all_log_handlers(cls):
+        # https://stackoverflow.com/questions/37703609/using-python-logging-with-aws-lambda
+        root = logging.getLogger()
+        if root.handlers:
+            for handler in root.handlers:
+                root.removeHandler(handler)
+
+    def handle(self, event, context):
+        self.logger.debug(f"Incoming event: {event}, context: {context}")
+
+        method = event.get("requestContext", {}).get("http", {}).get("method")
+        if method is None:
+            method = event.get("requestContext", {}).get("httpMethod")
+
+        if method is None:
+            return not_found()
+        if method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                bolt_req: BoltRequest = to_bolt_request(event)
+                query = bolt_req.query
+                is_callback = query is not None and (
+                    (_first_value(query, "code") is not None and _first_value(query, "state") is not None)
+                    or _first_value(query, "error") is not None
+                )
+                if is_callback:
+                    bolt_resp = oauth_flow.handle_callback(bolt_req)
+                    return to_aws_response(bolt_resp)
+                else:
+                    bolt_resp = oauth_flow.handle_installation(bolt_req)
+                    return to_aws_response(bolt_resp)
+        elif method == "POST":
+            bolt_req = to_bolt_request(event)
+            # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
+            aws_lambda_function_name = context.function_name
+            bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
+            bolt_req.context["aws_lambda_invoked_function_arn"] = context.invoked_function_arn
+            bolt_req.context["lambda_request"] = event
+            bolt_resp = self.app.dispatch(bolt_req)
+            aws_response = to_aws_response(bolt_resp)
+            return aws_response
+        elif method == "NONE":
+            bolt_req = to_bolt_request(event)
+            bolt_resp = self.app.dispatch(bolt_req)
+            aws_response = to_aws_response(bolt_resp)
+            return aws_response
+
+        return not_found()
+
+
+

Static methods

+
+
+def clear_all_log_handlers() +
+
+
+
+
+

Methods

+
+
+def handle(self, event, context) +
+
+
+ +Expand source code + +
def handle(self, event, context):
+    self.logger.debug(f"Incoming event: {event}, context: {context}")
+
+    method = event.get("requestContext", {}).get("http", {}).get("method")
+    if method is None:
+        method = event.get("requestContext", {}).get("httpMethod")
+
+    if method is None:
+        return not_found()
+    if method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            bolt_req: BoltRequest = to_bolt_request(event)
+            query = bolt_req.query
+            is_callback = query is not None and (
+                (_first_value(query, "code") is not None and _first_value(query, "state") is not None)
+                or _first_value(query, "error") is not None
+            )
+            if is_callback:
+                bolt_resp = oauth_flow.handle_callback(bolt_req)
+                return to_aws_response(bolt_resp)
+            else:
+                bolt_resp = oauth_flow.handle_installation(bolt_req)
+                return to_aws_response(bolt_resp)
+    elif method == "POST":
+        bolt_req = to_bolt_request(event)
+        # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html
+        aws_lambda_function_name = context.function_name
+        bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name
+        bolt_req.context["aws_lambda_invoked_function_arn"] = context.invoked_function_arn
+        bolt_req.context["lambda_request"] = event
+        bolt_resp = self.app.dispatch(bolt_req)
+        aws_response = to_aws_response(bolt_resp)
+        return aws_response
+    elif method == "NONE":
+        bolt_req = to_bolt_request(event)
+        bolt_resp = self.app.dispatch(bolt_req)
+        aws_response = to_aws_response(bolt_resp)
+        return aws_response
+
+    return not_found()
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/internals.html b/docs/reference/adapter/aws_lambda/internals.html new file mode 100644 index 000000000..bbbe281b0 --- /dev/null +++ b/docs/reference/adapter/aws_lambda/internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.adapter.aws_lambda.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda.internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html b/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html new file mode 100644 index 000000000..11845c902 --- /dev/null +++ b/docs/reference/adapter/aws_lambda/lambda_s3_oauth_flow.html @@ -0,0 +1,210 @@ + + + + + + +slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class LambdaS3OAuthFlow +(*,
client: slack_sdk.web.client.WebClient | None = None,
logger: logging.Logger | None = None,
settings: OAuthSettings | None = None,
oauth_state_bucket_name: str | None = None,
installation_bucket_name: str | None = None)
+
+
+
+ +Expand source code + +
class LambdaS3OAuthFlow(OAuthFlow):
+    def __init__(
+        self,
+        *,
+        client: Optional[WebClient] = None,
+        logger: Optional[Logger] = None,
+        settings: Optional[OAuthSettings] = None,
+        oauth_state_bucket_name: Optional[str] = None,  # required
+        installation_bucket_name: Optional[str] = None,  # required
+    ):
+        logger = logger or logging.getLogger(__name__)
+        settings = settings or OAuthSettings(
+            client_id=os.environ["SLACK_CLIENT_ID"],
+            client_secret=os.environ["SLACK_CLIENT_SECRET"],
+        )
+        oauth_state_bucket_name = oauth_state_bucket_name or os.environ["SLACK_STATE_S3_BUCKET_NAME"]
+        installation_bucket_name = installation_bucket_name or os.environ["SLACK_INSTALLATION_S3_BUCKET_NAME"]
+        self.s3_client = boto3.client("s3")
+        if settings.state_store is None or not isinstance(settings.state_store, AmazonS3OAuthStateStore):
+            settings.state_store = AmazonS3OAuthStateStore(
+                logger=logger,
+                s3_client=self.s3_client,
+                bucket_name=oauth_state_bucket_name,
+                expiration_seconds=settings.state_expiration_seconds,
+            )
+
+        if settings.installation_store is None or not isinstance(settings.installation_store, AmazonS3InstallationStore):
+            settings.installation_store = AmazonS3InstallationStore(
+                logger=logger,
+                s3_client=self.s3_client,
+                bucket_name=installation_bucket_name,
+                client_id=settings.client_id,
+            )
+
+        # Set up authorize function to surely use this installation_store.
+        # When a developer use a settings initialized outside this constructor,
+        # the settings may already have pre-defined authorize.
+        # In this case, the /slack/events endpoint doesn't work along with the OAuth flow.
+        settings.authorize = InstallationStoreAuthorize(
+            logger=logger,
+            client_id=settings.client_id,
+            client_secret=settings.client_secret,
+            installation_store=settings.installation_store,
+            bot_only=settings.installation_store_bot_only,
+            user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"),
+        )
+
+        OAuthFlow.__init__(self, client=client, logger=logger, settings=settings)
+
+    @property
+    def client(self) -> WebClient:
+        if self._client is None:
+            self._client = create_web_client(logger=self.logger)
+        return self._client
+
+    @property
+    def logger(self) -> Logger:
+        if self._logger is None:
+            self._logger = logging.getLogger(__name__)
+        return self._logger
+
+

The module to run the Slack app installation flow (OAuth flow).

+

Args

+
+
client
+
The slack_sdk.web.WebClient instance.
+
logger
+
The logger.
+
settings
+
OAuth settings to configure this module.
+
+

Ancestors

+ +

Instance variables

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    if self._client is None:
+        self._client = create_web_client(logger=self.logger)
+    return self._client
+
+
+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> Logger:
+    if self._logger is None:
+        self._logger = logging.getLogger(__name__)
+    return self._logger
+
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/lazy_listener_runner.html b/docs/reference/adapter/aws_lambda/lazy_listener_runner.html new file mode 100644 index 000000000..df53f5f22 --- /dev/null +++ b/docs/reference/adapter/aws_lambda/lazy_listener_runner.html @@ -0,0 +1,122 @@ + + + + + + +slack_bolt.adapter.aws_lambda.lazy_listener_runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda.lazy_listener_runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class LambdaLazyListenerRunner +(logger: logging.Logger, lambda_client: Any | None = None) +
+
+
+ +Expand source code + +
class LambdaLazyListenerRunner(LazyListenerRunner):
+    def __init__(self, logger: Logger, lambda_client: Optional[Any] = None):
+        self.lambda_client = lambda_client
+        self.logger = logger
+
+    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+        if self.lambda_client is None:
+            self.lambda_client = boto3.client("lambda")
+
+        event: dict = request.context["lambda_request"]
+        headers = event["headers"]
+        headers["x-slack-bolt-lazy-only"] = "1"  # not an array
+        headers["x-slack-bolt-lazy-function-name"] = request.lazy_function_name  # not an array
+        event["method"] = "NONE"
+        invocation = self.lambda_client.invoke(
+            FunctionName=request.context["aws_lambda_invoked_function_arn"],
+            InvocationType="Event",
+            Payload=json.dumps(event),
+        )
+        self.logger.info(invocation)
+
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/aws_lambda/local_lambda_client.html b/docs/reference/adapter/aws_lambda/local_lambda_client.html new file mode 100644 index 000000000..45ee0510b --- /dev/null +++ b/docs/reference/adapter/aws_lambda/local_lambda_client.html @@ -0,0 +1,140 @@ + + + + + + +slack_bolt.adapter.aws_lambda.local_lambda_client API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.aws_lambda.local_lambda_client

+
+
+
+
+
+
+
+
+
+
+

Classes

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

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

+

Ancestors

+
    +
  • chalice.test.BaseClient
  • +
+

Methods

+
+
+def invoke(self, FunctionName: str, InvocationType: str = 'Event', Payload: str = '{}') ‑> chalice.test.InvokeResponse +
+
+
+ +Expand source code + +
def invoke(
+    self,
+    FunctionName: str,
+    InvocationType: str = "Event",
+    Payload: str = "{}",
+) -> InvokeResponse:
+    scoped = self._config.scope(self._config.chalice_stage, FunctionName)
+    lambda_context = LambdaContext(FunctionName, memory_size=scoped.lambda_memory_size)
+
+    with self._patched_env_vars(scoped.environment_variables):
+        response = self._app(json.loads(Payload), lambda_context)
+    return InvokeResponse(payload=response)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/bottle/handler.html b/docs/reference/adapter/bottle/handler.html new file mode 100644 index 000000000..fe6f8ae1a --- /dev/null +++ b/docs/reference/adapter/bottle/handler.html @@ -0,0 +1,192 @@ + + + + + + +slack_bolt.adapter.bottle.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.bottle.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def set_response(bolt_resp: BoltResponse,
resp: bottle.BaseResponse) ‑> None
+
+
+
+ +Expand source code + +
def set_response(bolt_resp: BoltResponse, resp: Response) -> None:
+    resp.status = bolt_resp.status
+    for k, values in bolt_resp.headers.items():
+        for v in values:
+            resp.add_header(k, v)
+
+
+
+
+def to_bolt_request(req: bottle.BaseRequest) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_bolt_request(req: Request) -> BoltRequest:
+    body = req.body.read()
+    if isinstance(body, bytes):
+        body = body.decode("utf-8")
+    return BoltRequest(
+        body=body,
+        query=req.query_string,
+        headers=req.headers,
+    )
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self, req: Request, resp: Response) -> str:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                    set_response(bolt_resp, resp)
+                    return bolt_resp.body or ""
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                    set_response(bolt_resp, resp)
+                    return bolt_resp.body or ""
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            set_response(bolt_resp, resp)
+            return bolt_resp.body or ""
+
+        resp.status = 404
+        return "Not Found"
+
+
+

Methods

+
+
+def handle(self, req: bottle.BaseRequest, resp: bottle.BaseResponse) ‑> str +
+
+
+ +Expand source code + +
def handle(self, req: Request, resp: Response) -> str:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                set_response(bolt_resp, resp)
+                return bolt_resp.body or ""
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                set_response(bolt_resp, resp)
+                return bolt_resp.body or ""
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        set_response(bolt_resp, resp)
+        return bolt_resp.body or ""
+
+    resp.status = 404
+    return "Not Found"
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/bottle/index.html b/docs/reference/adapter/bottle/index.html new file mode 100644 index 000000000..f240d52bc --- /dev/null +++ b/docs/reference/adapter/bottle/index.html @@ -0,0 +1,159 @@ + + + + + + +slack_bolt.adapter.bottle API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.bottle

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.bottle.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self, req: Request, resp: Response) -> str:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                    set_response(bolt_resp, resp)
+                    return bolt_resp.body or ""
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                    set_response(bolt_resp, resp)
+                    return bolt_resp.body or ""
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            set_response(bolt_resp, resp)
+            return bolt_resp.body or ""
+
+        resp.status = 404
+        return "Not Found"
+
+
+

Methods

+
+
+def handle(self, req: bottle.BaseRequest, resp: bottle.BaseResponse) ‑> str +
+
+
+ +Expand source code + +
def handle(self, req: Request, resp: Response) -> str:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                set_response(bolt_resp, resp)
+                return bolt_resp.body or ""
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                set_response(bolt_resp, resp)
+                return bolt_resp.body or ""
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        set_response(bolt_resp, resp)
+        return bolt_resp.body or ""
+
+    resp.status = 404
+    return "Not Found"
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/cherrypy/handler.html b/docs/reference/adapter/cherrypy/handler.html new file mode 100644 index 000000000..d41f00148 --- /dev/null +++ b/docs/reference/adapter/cherrypy/handler.html @@ -0,0 +1,234 @@ + + + + + + +slack_bolt.adapter.cherrypy.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.cherrypy.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_bolt_request() ‑> BoltRequest +
+
+
+ +Expand source code + +
def build_bolt_request() -> BoltRequest:
+    req = cherrypy.request
+    body = req.raw_body if hasattr(req, "raw_body") else ""
+    return BoltRequest(
+        body=body,
+        query=req.query_string,
+        headers=req.headers,
+    )
+
+
+
+
+def set_response_status_and_headers(bolt_resp: BoltResponse) ‑> None +
+
+
+ +Expand source code + +
def set_response_status_and_headers(bolt_resp: BoltResponse) -> None:
+    cherrypy.response.status = bolt_resp.status
+    for k, v in bolt_resp.first_headers_without_set_cookie().items():
+        cherrypy.response.headers[k] = v
+    for cookie in bolt_resp.cookies():
+        for name, c in cookie.items():
+            str_max_age: Optional[str] = c.get("max-age")
+            max_age: Optional[int] = int(str_max_age) if str_max_age else None
+            cherrypy_cookie = cherrypy.response.cookie
+            cherrypy_cookie[name] = c.value
+            cherrypy_cookie[name]["expires"] = c.get("expires")
+            cherrypy_cookie[name]["max-age"] = max_age
+            cherrypy_cookie[name]["domain"] = c.get("domain")
+            cherrypy_cookie[name]["path"] = c.get("path")
+            cherrypy_cookie[name]["secure"] = True
+            cherrypy_cookie[name]["httponly"] = True
+
+
+
+
+def slack_in() +
+
+
+ +Expand source code + +
@cherrypy.tools.register("on_start_resource")
+def slack_in():
+    request = cherrypy.serving.request
+
+    def slack_processor(entity):
+        try:
+            if request.process_request_body:
+                body = entity.fp.read()
+                body = body.decode("utf-8") if isinstance(body, bytes) else ""
+                request.raw_body = body
+        except ValueError:
+            raise cherrypy.HTTPError(400, "Invalid request body")
+
+    request.body.processors.clear()
+    request.body.processors["application/json"] = slack_processor
+    request.body.processors["application/x-www-form-urlencoded"] = slack_processor
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self) -> bytes:
+        req = cherrypy.request
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0]
+                if request_path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(build_bolt_request())
+                    set_response_status_and_headers(bolt_resp)
+                    return (bolt_resp.body or "").encode("utf-8")
+                elif request_path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(build_bolt_request())
+                    set_response_status_and_headers(bolt_resp)
+                    return (bolt_resp.body or "").encode("utf-8")
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(build_bolt_request())
+            set_response_status_and_headers(bolt_resp)
+            return (bolt_resp.body or "").encode("utf-8")
+
+        cherrypy.response.status = 404
+        return "Not Found".encode("utf-8")
+
+
+

Methods

+
+
+def handle(self) ‑> bytes +
+
+
+ +Expand source code + +
def handle(self) -> bytes:
+    req = cherrypy.request
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0]
+            if request_path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(build_bolt_request())
+                set_response_status_and_headers(bolt_resp)
+                return (bolt_resp.body or "").encode("utf-8")
+            elif request_path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(build_bolt_request())
+                set_response_status_and_headers(bolt_resp)
+                return (bolt_resp.body or "").encode("utf-8")
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(build_bolt_request())
+        set_response_status_and_headers(bolt_resp)
+        return (bolt_resp.body or "").encode("utf-8")
+
+    cherrypy.response.status = 404
+    return "Not Found".encode("utf-8")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/cherrypy/index.html b/docs/reference/adapter/cherrypy/index.html new file mode 100644 index 000000000..5a322fd7a --- /dev/null +++ b/docs/reference/adapter/cherrypy/index.html @@ -0,0 +1,163 @@ + + + + + + +slack_bolt.adapter.cherrypy API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.cherrypy

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.cherrypy.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self) -> bytes:
+        req = cherrypy.request
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0]
+                if request_path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(build_bolt_request())
+                    set_response_status_and_headers(bolt_resp)
+                    return (bolt_resp.body or "").encode("utf-8")
+                elif request_path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(build_bolt_request())
+                    set_response_status_and_headers(bolt_resp)
+                    return (bolt_resp.body or "").encode("utf-8")
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(build_bolt_request())
+            set_response_status_and_headers(bolt_resp)
+            return (bolt_resp.body or "").encode("utf-8")
+
+        cherrypy.response.status = 404
+        return "Not Found".encode("utf-8")
+
+
+

Methods

+
+
+def handle(self) ‑> bytes +
+
+
+ +Expand source code + +
def handle(self) -> bytes:
+    req = cherrypy.request
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0]
+            if request_path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(build_bolt_request())
+                set_response_status_and_headers(bolt_resp)
+                return (bolt_resp.body or "").encode("utf-8")
+            elif request_path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(build_bolt_request())
+                set_response_status_and_headers(bolt_resp)
+                return (bolt_resp.body or "").encode("utf-8")
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(build_bolt_request())
+        set_response_status_and_headers(bolt_resp)
+        return (bolt_resp.body or "").encode("utf-8")
+
+    cherrypy.response.status = 404
+    return "Not Found".encode("utf-8")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/django/handler.html b/docs/reference/adapter/django/handler.html new file mode 100644 index 000000000..4fe9e359a --- /dev/null +++ b/docs/reference/adapter/django/handler.html @@ -0,0 +1,388 @@ + + + + + + +slack_bolt.adapter.django.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.django.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def release_thread_local_connections(logger: logging.Logger, execution_timing: str) +
+
+
+ +Expand source code + +
def release_thread_local_connections(logger: Logger, execution_timing: str):
+    close_old_connections()
+    if logger.level <= logging.DEBUG:
+        current: Thread = current_thread()
+        logger.debug(
+            "Released thread-bound old DB connections "
+            f"(thread name: {current.name}, execution timing: {execution_timing})"
+        )
+
+
+
+
+def to_bolt_request(req: django.http.request.HttpRequest) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_bolt_request(req: HttpRequest) -> BoltRequest:
+    raw_body: bytes = req.body
+    body: str = raw_body.decode("utf-8") if raw_body else ""
+    return BoltRequest(
+        body=body,
+        query=req.META["QUERY_STRING"],
+        headers=req.headers,
+    )
+
+
+
+
+def to_django_response(bolt_resp: BoltResponse) ‑> django.http.response.HttpResponse +
+
+
+ +Expand source code + +
def to_django_response(bolt_resp: BoltResponse) -> HttpResponse:
+    resp: HttpResponse = HttpResponse(
+        status=bolt_resp.status,
+        content=bolt_resp.body.encode("utf-8"),
+    )
+    for k, v in bolt_resp.first_headers_without_set_cookie().items():
+        resp[k] = v
+
+    for cookie in bolt_resp.cookies():
+        for name, c in cookie.items():
+            str_max_age: Optional[str] = c.get("max-age")
+            max_age: Optional[int] = int(str_max_age) if str_max_age else None
+            resp.set_cookie(
+                key=name,
+                value=c.value,
+                expires=c.get("expires"),
+                max_age=max_age,
+                domain=c.get("domain"),
+                path=c.get("path"),
+                secure=True,
+                httponly=True,
+            )
+    return resp
+
+
+
+
+
+
+

Classes

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

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

+

Ancestors

+ +

Inherited members

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

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

+

Ancestors

+ +

Inherited members

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

Ancestors

+ +

Inherited members

+ +
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+        listener_runner = self.app.listener_runner
+        # This runner closes all thread-local connections in the thread when an execution completes
+        self.app.listener_runner.lazy_listener_runner = DjangoThreadLazyListenerRunner(
+            logger=listener_runner.logger,
+            executor=listener_runner.listener_executor,
+        )
+
+        if not isinstance(listener_runner, ThreadListenerRunner):
+            raise BoltError("Custom listener_runners are not compatible with this Django adapter.")
+
+        if app.process_before_response is True:
+            # As long as the app access Django models in the same thread,
+            # Django cleans the connections up for you.
+            self.app.logger.debug("App.process_before_response is set to True")
+            return
+
+        current_start_handler = listener_runner.listener_start_handler
+        if current_start_handler is not None and not isinstance(current_start_handler, DefaultListenerStartHandler):
+            # As we run release_thread_local_connections() before listener executions,
+            # it's okay to skip calling the same connection clean-up method at the listener completion.
+            message = """As you've already set app.listener_runner.listener_start_handler to your own one,
+            Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler.
+
+            If you go with your own handler here, we highly recommend having the following lines of code
+            in your handle() method to clean up unmanaged stale/old database connections:
+
+            from django.db import close_old_connections
+            close_old_connections()
+            """
+            self.app.logger.info(message)
+        else:
+            # for proper management of thread-local Django DB connections
+            self.app.listener_runner.listener_start_handler = DjangoListenerStartHandler()
+            self.app.logger.debug("DjangoListenerStartHandler has been enabled")
+
+        current_completion_handler = listener_runner.listener_completion_handler
+        if current_completion_handler is not None and not isinstance(
+            current_completion_handler, DefaultListenerCompletionHandler
+        ):
+            # As we run release_thread_local_connections() before listener executions,
+            # it's okay to skip calling the same connection clean-up method at the listener completion.
+            message = """As you've already set app.listener_runner.listener_completion_handler to your own one,
+            Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler.
+            """
+            self.app.logger.info(message)
+            return
+        # for proper management of thread-local Django DB connections
+        self.app.listener_runner.listener_completion_handler = DjangoListenerCompletionHandler()
+        self.app.logger.debug("DjangoListenerCompletionHandler has been enabled")
+
+    def handle(self, req: HttpRequest) -> HttpResponse:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                    return to_django_response(bolt_resp)
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                    return to_django_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            return to_django_response(bolt_resp)
+
+        return HttpResponse(status=404, content=b"Not Found")
+
+
+

Methods

+
+
+def handle(self, req: django.http.request.HttpRequest) ‑> django.http.response.HttpResponse +
+
+
+ +Expand source code + +
def handle(self, req: HttpRequest) -> HttpResponse:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                return to_django_response(bolt_resp)
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                return to_django_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        return to_django_response(bolt_resp)
+
+    return HttpResponse(status=404, content=b"Not Found")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/django/index.html b/docs/reference/adapter/django/index.html new file mode 100644 index 000000000..dfb6af63f --- /dev/null +++ b/docs/reference/adapter/django/index.html @@ -0,0 +1,200 @@ + + + + + + +slack_bolt.adapter.django API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.django

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.django.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+        listener_runner = self.app.listener_runner
+        # This runner closes all thread-local connections in the thread when an execution completes
+        self.app.listener_runner.lazy_listener_runner = DjangoThreadLazyListenerRunner(
+            logger=listener_runner.logger,
+            executor=listener_runner.listener_executor,
+        )
+
+        if not isinstance(listener_runner, ThreadListenerRunner):
+            raise BoltError("Custom listener_runners are not compatible with this Django adapter.")
+
+        if app.process_before_response is True:
+            # As long as the app access Django models in the same thread,
+            # Django cleans the connections up for you.
+            self.app.logger.debug("App.process_before_response is set to True")
+            return
+
+        current_start_handler = listener_runner.listener_start_handler
+        if current_start_handler is not None and not isinstance(current_start_handler, DefaultListenerStartHandler):
+            # As we run release_thread_local_connections() before listener executions,
+            # it's okay to skip calling the same connection clean-up method at the listener completion.
+            message = """As you've already set app.listener_runner.listener_start_handler to your own one,
+            Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler.
+
+            If you go with your own handler here, we highly recommend having the following lines of code
+            in your handle() method to clean up unmanaged stale/old database connections:
+
+            from django.db import close_old_connections
+            close_old_connections()
+            """
+            self.app.logger.info(message)
+        else:
+            # for proper management of thread-local Django DB connections
+            self.app.listener_runner.listener_start_handler = DjangoListenerStartHandler()
+            self.app.logger.debug("DjangoListenerStartHandler has been enabled")
+
+        current_completion_handler = listener_runner.listener_completion_handler
+        if current_completion_handler is not None and not isinstance(
+            current_completion_handler, DefaultListenerCompletionHandler
+        ):
+            # As we run release_thread_local_connections() before listener executions,
+            # it's okay to skip calling the same connection clean-up method at the listener completion.
+            message = """As you've already set app.listener_runner.listener_completion_handler to your own one,
+            Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler.
+            """
+            self.app.logger.info(message)
+            return
+        # for proper management of thread-local Django DB connections
+        self.app.listener_runner.listener_completion_handler = DjangoListenerCompletionHandler()
+        self.app.logger.debug("DjangoListenerCompletionHandler has been enabled")
+
+    def handle(self, req: HttpRequest) -> HttpResponse:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                    return to_django_response(bolt_resp)
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                    return to_django_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            return to_django_response(bolt_resp)
+
+        return HttpResponse(status=404, content=b"Not Found")
+
+
+

Methods

+
+
+def handle(self, req: django.http.request.HttpRequest) ‑> django.http.response.HttpResponse +
+
+
+ +Expand source code + +
def handle(self, req: HttpRequest) -> HttpResponse:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                return to_django_response(bolt_resp)
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                return to_django_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        return to_django_response(bolt_resp)
+
+    return HttpResponse(status=404, content=b"Not Found")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/falcon/async_resource.html b/docs/reference/adapter/falcon/async_resource.html new file mode 100644 index 000000000..0dbba1ad4 --- /dev/null +++ b/docs/reference/adapter/falcon/async_resource.html @@ -0,0 +1,206 @@ + + + + + + +slack_bolt.adapter.falcon.async_resource API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.falcon.async_resource

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackAppResource +(app: AsyncApp) +
+
+
+ +Expand source code + +
class AsyncSlackAppResource:
+    """
+    For use with ASGI Falcon Apps.
+
+    from slack_bolt.async_app import AsyncApp
+    app = AsyncApp()
+
+    import falcon
+    app = falcon.asgi.App()
+    app.add_route("/slack/events", AsyncSlackAppResource(app))
+    """
+
+    def __init__(self, app: AsyncApp):
+        if falcon_version.__version__.startswith("2."):
+            raise BoltError("This ASGI compatible adapter requires Falcon version >= 3.0")
+
+        self.app = app
+
+    async def on_get(self, req: Request, resp: Response):
+        if self.app.oauth_flow is not None:
+            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = await oauth_flow.handle_installation(await self._to_bolt_request(req))
+                await self._write_response(bolt_resp, resp)
+                return
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = await oauth_flow.handle_callback(await self._to_bolt_request(req))
+                await self._write_response(bolt_resp, resp)
+                return
+
+        resp.status = 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)
+        bolt_resp = await self.app.async_dispatch(bolt_req)
+        await self._write_response(bolt_resp, resp)
+
+    async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest:
+        return AsyncBoltRequest(
+            body=(await req.stream.read(req.content_length or 0)).decode("utf-8"),
+            query=req.query_string,
+            headers={k.lower(): v for k, v in req.headers.items()},
+        )
+
+    async def _write_response(self, bolt_resp: BoltResponse, resp: Response):
+        resp.text = bolt_resp.body
+        status = HTTPStatus(bolt_resp.status)
+        resp.status = str(f"{status.value} {status.phrase}")
+        resp.set_headers(bolt_resp.first_headers_without_set_cookie())
+        for cookie in bolt_resp.cookies():
+            for name, c in cookie.items():
+                expire_value = c.get("expires")
+                expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None
+                resp.set_cookie(
+                    name=name,
+                    value=c.value,
+                    expires=expire,
+                    max_age=c.get("max-age"),
+                    domain=c.get("domain"),
+                    path=c.get("path"),
+                    secure=True,
+                    http_only=True,
+                )
+
+

For use with ASGI Falcon Apps.

+

from slack_bolt.async_app import AsyncApp +app = AsyncApp()

+

import falcon +app = falcon.asgi.App() +app.add_route("/slack/events", AsyncSlackAppResource(app))

+

Methods

+
+
+async def on_get(self, req: falcon.asgi.request.Request, resp: falcon.asgi.response.Response) +
+
+
+ +Expand source code + +
async def on_get(self, req: Request, resp: Response):
+    if self.app.oauth_flow is not None:
+        oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+        if req.path == oauth_flow.install_path:
+            bolt_resp = await oauth_flow.handle_installation(await self._to_bolt_request(req))
+            await self._write_response(bolt_resp, resp)
+            return
+        elif req.path == oauth_flow.redirect_uri_path:
+            bolt_resp = await oauth_flow.handle_callback(await self._to_bolt_request(req))
+            await self._write_response(bolt_resp, resp)
+            return
+
+    resp.status = HTTPStatus.NOT_FOUND
+    resp.content_type = MEDIA_TEXT
+    resp.text = "The page is not found..."
+
+
+
+
+async def on_post(self, req: falcon.asgi.request.Request, resp: falcon.asgi.response.Response) +
+
+
+ +Expand source code + +
async def on_post(self, req: Request, resp: Response):
+    bolt_req = await self._to_bolt_request(req)
+    bolt_resp = await self.app.async_dispatch(bolt_req)
+    await self._write_response(bolt_resp, resp)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/falcon/index.html b/docs/reference/adapter/falcon/index.html new file mode 100644 index 000000000..bfc21828f --- /dev/null +++ b/docs/reference/adapter/falcon/index.html @@ -0,0 +1,222 @@ + + + + + + +slack_bolt.adapter.falcon API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.falcon

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.falcon.async_resource
+
+
+
+
slack_bolt.adapter.falcon.resource
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackAppResource +(app: App) +
+
+
+ +Expand source code + +
class SlackAppResource:
+    """
+    from slack_bolt import App
+    app = App()
+
+    import falcon
+    api = application = falcon.API()
+    api.add_route("/slack/events", SlackAppResource(app))
+    """
+
+    def __init__(self, app: App):
+        self.app = app
+
+    def on_get(self, req: Request, resp: Response):
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(self._to_bolt_request(req))
+                self._write_response(bolt_resp, resp)
+                return
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(self._to_bolt_request(req))
+                self._write_response(bolt_resp, resp)
+                return
+
+        resp.status = 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)
+        bolt_resp = self.app.dispatch(bolt_req)
+        self._write_response(bolt_resp, resp)
+
+    def _to_bolt_request(self, req: Request) -> BoltRequest:
+        return BoltRequest(
+            body=req.stream.read(req.content_length or 0).decode("utf-8"),
+            query=req.query_string,
+            headers={k.lower(): v for k, v in req.headers.items()},
+        )
+
+    def _write_response(self, bolt_resp: BoltResponse, resp: Response):
+        if falcon_version.__version__.startswith("2."):
+            # Falcon 4.x w/ mypy fails to correctly infer the str type here
+            resp.body = bolt_resp.body
+        else:
+            resp.text = bolt_resp.body
+
+        status = HTTPStatus(bolt_resp.status)
+        resp.status = str(f"{status.value} {status.phrase}")
+        resp.set_headers(bolt_resp.first_headers_without_set_cookie())
+        for cookie in bolt_resp.cookies():
+            for name, c in cookie.items():
+                expire_value = c.get("expires")
+                expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None
+                resp.set_cookie(
+                    name=name,
+                    value=c.value,
+                    expires=expire,
+                    max_age=c.get("max-age"),
+                    domain=c.get("domain"),
+                    path=c.get("path"),
+                    secure=True,
+                    http_only=True,
+                )
+
+

from slack_bolt import App +app = App()

+

import falcon +api = application = falcon.API() +api.add_route("/slack/events", SlackAppResource(app))

+

Methods

+
+
+def on_get(self, req: falcon.request.Request, resp: falcon.response.Response) +
+
+
+ +Expand source code + +
def on_get(self, req: Request, resp: Response):
+    if self.app.oauth_flow is not None:
+        oauth_flow: OAuthFlow = self.app.oauth_flow
+        if req.path == oauth_flow.install_path:
+            bolt_resp = oauth_flow.handle_installation(self._to_bolt_request(req))
+            self._write_response(bolt_resp, resp)
+            return
+        elif req.path == oauth_flow.redirect_uri_path:
+            bolt_resp = oauth_flow.handle_callback(self._to_bolt_request(req))
+            self._write_response(bolt_resp, resp)
+            return
+
+    resp.status = HTTPStatus.NOT_FOUND
+    resp.content_type = MEDIA_TEXT
+    resp.text = "The page is not found..."
+
+
+
+
+def on_post(self, req: falcon.request.Request, resp: falcon.response.Response) +
+
+
+ +Expand source code + +
def on_post(self, req: Request, resp: Response):
+    bolt_req = self._to_bolt_request(req)
+    bolt_resp = self.app.dispatch(bolt_req)
+    self._write_response(bolt_resp, resp)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/falcon/resource.html b/docs/reference/adapter/falcon/resource.html new file mode 100644 index 000000000..13f9a2177 --- /dev/null +++ b/docs/reference/adapter/falcon/resource.html @@ -0,0 +1,205 @@ + + + + + + +slack_bolt.adapter.falcon.resource API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.falcon.resource

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackAppResource +(app: App) +
+
+
+ +Expand source code + +
class SlackAppResource:
+    """
+    from slack_bolt import App
+    app = App()
+
+    import falcon
+    api = application = falcon.API()
+    api.add_route("/slack/events", SlackAppResource(app))
+    """
+
+    def __init__(self, app: App):
+        self.app = app
+
+    def on_get(self, req: Request, resp: Response):
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(self._to_bolt_request(req))
+                self._write_response(bolt_resp, resp)
+                return
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(self._to_bolt_request(req))
+                self._write_response(bolt_resp, resp)
+                return
+
+        resp.status = 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)
+        bolt_resp = self.app.dispatch(bolt_req)
+        self._write_response(bolt_resp, resp)
+
+    def _to_bolt_request(self, req: Request) -> BoltRequest:
+        return BoltRequest(
+            body=req.stream.read(req.content_length or 0).decode("utf-8"),
+            query=req.query_string,
+            headers={k.lower(): v for k, v in req.headers.items()},
+        )
+
+    def _write_response(self, bolt_resp: BoltResponse, resp: Response):
+        if falcon_version.__version__.startswith("2."):
+            # Falcon 4.x w/ mypy fails to correctly infer the str type here
+            resp.body = bolt_resp.body
+        else:
+            resp.text = bolt_resp.body
+
+        status = HTTPStatus(bolt_resp.status)
+        resp.status = str(f"{status.value} {status.phrase}")
+        resp.set_headers(bolt_resp.first_headers_without_set_cookie())
+        for cookie in bolt_resp.cookies():
+            for name, c in cookie.items():
+                expire_value = c.get("expires")
+                expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None
+                resp.set_cookie(
+                    name=name,
+                    value=c.value,
+                    expires=expire,
+                    max_age=c.get("max-age"),
+                    domain=c.get("domain"),
+                    path=c.get("path"),
+                    secure=True,
+                    http_only=True,
+                )
+
+

from slack_bolt import App +app = App()

+

import falcon +api = application = falcon.API() +api.add_route("/slack/events", SlackAppResource(app))

+

Methods

+
+
+def on_get(self, req: falcon.request.Request, resp: falcon.response.Response) +
+
+
+ +Expand source code + +
def on_get(self, req: Request, resp: Response):
+    if self.app.oauth_flow is not None:
+        oauth_flow: OAuthFlow = self.app.oauth_flow
+        if req.path == oauth_flow.install_path:
+            bolt_resp = oauth_flow.handle_installation(self._to_bolt_request(req))
+            self._write_response(bolt_resp, resp)
+            return
+        elif req.path == oauth_flow.redirect_uri_path:
+            bolt_resp = oauth_flow.handle_callback(self._to_bolt_request(req))
+            self._write_response(bolt_resp, resp)
+            return
+
+    resp.status = HTTPStatus.NOT_FOUND
+    resp.content_type = MEDIA_TEXT
+    resp.text = "The page is not found..."
+
+
+
+
+def on_post(self, req: falcon.request.Request, resp: falcon.response.Response) +
+
+
+ +Expand source code + +
def on_post(self, req: Request, resp: Response):
+    bolt_req = self._to_bolt_request(req)
+    bolt_resp = self.app.dispatch(bolt_req)
+    self._write_response(bolt_resp, resp)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/fastapi/async_handler.html b/docs/reference/adapter/fastapi/async_handler.html new file mode 100644 index 000000000..6f6205e51 --- /dev/null +++ b/docs/reference/adapter/fastapi/async_handler.html @@ -0,0 +1,155 @@ + + + + + + +slack_bolt.adapter.fastapi.async_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.fastapi.async_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackRequestHandler +(app: AsyncApp) +
+
+
+ +Expand source code + +
class AsyncSlackRequestHandler:
+    def __init__(self, app: AsyncApp):
+        self.app = app
+
+    async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+        body = await req.body()
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+                if req.url.path == oauth_flow.install_path:
+                    bolt_resp = await oauth_flow.handle_installation(
+                        to_async_bolt_request(req, body, addition_context_properties)
+                    )
+                    return to_starlette_response(bolt_resp)
+                elif req.url.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = await oauth_flow.handle_callback(
+                        to_async_bolt_request(req, body, addition_context_properties)
+                    )
+                    return to_starlette_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body, addition_context_properties))
+            return to_starlette_response(bolt_resp)
+
+        return Response(
+            status_code=404,
+            content="Not found",
+        )
+
+
+

Methods

+
+
+async def handle(self,
req: starlette.requests.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> starlette.responses.Response
+
+
+
+ +Expand source code + +
async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+    body = await req.body()
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+            if req.url.path == oauth_flow.install_path:
+                bolt_resp = await oauth_flow.handle_installation(
+                    to_async_bolt_request(req, body, addition_context_properties)
+                )
+                return to_starlette_response(bolt_resp)
+            elif req.url.path == oauth_flow.redirect_uri_path:
+                bolt_resp = await oauth_flow.handle_callback(
+                    to_async_bolt_request(req, body, addition_context_properties)
+                )
+                return to_starlette_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body, addition_context_properties))
+        return to_starlette_response(bolt_resp)
+
+    return Response(
+        status_code=404,
+        content="Not found",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/fastapi/index.html b/docs/reference/adapter/fastapi/index.html new file mode 100644 index 000000000..6ffb52f35 --- /dev/null +++ b/docs/reference/adapter/fastapi/index.html @@ -0,0 +1,159 @@ + + + + + + +slack_bolt.adapter.fastapi API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.fastapi

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.fastapi.async_handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+        body = await req.body()
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.url.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req, body, addition_context_properties))
+                    return to_starlette_response(bolt_resp)
+                elif req.url.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body, addition_context_properties))
+                    return to_starlette_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req, body, addition_context_properties))
+            return to_starlette_response(bolt_resp)
+
+        return Response(
+            status_code=404,
+            content="Not found",
+        )
+
+
+

Methods

+
+
+async def handle(self,
req: starlette.requests.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> starlette.responses.Response
+
+
+
+ +Expand source code + +
async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+    body = await req.body()
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.url.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req, body, addition_context_properties))
+                return to_starlette_response(bolt_resp)
+            elif req.url.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body, addition_context_properties))
+                return to_starlette_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req, body, addition_context_properties))
+        return to_starlette_response(bolt_resp)
+
+    return Response(
+        status_code=404,
+        content="Not found",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/flask/handler.html b/docs/reference/adapter/flask/handler.html new file mode 100644 index 000000000..489b80a90 --- /dev/null +++ b/docs/reference/adapter/flask/handler.html @@ -0,0 +1,185 @@ + + + + + + +slack_bolt.adapter.flask.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.flask.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def to_bolt_request(req: flask.wrappers.Request) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_bolt_request(req: Request) -> BoltRequest:
+    return BoltRequest(
+        body=req.get_data(as_text=True),
+        query=req.query_string.decode("utf-8"),
+        headers=req.headers,  # type: ignore[arg-type]
+    )
+
+
+
+
+def to_flask_response(bolt_resp: BoltResponse) ‑> flask.wrappers.Response +
+
+
+ +Expand source code + +
def to_flask_response(bolt_resp: BoltResponse) -> Response:
+    resp: Response = make_response(bolt_resp.body, bolt_resp.status)
+    for k, values in bolt_resp.headers.items():
+        if k.lower() == "content-type" and resp.headers.get("content-type") is not None:
+            # Remove the one set by Flask
+            resp.headers.pop("content-type")
+        for v in values:
+            resp.headers.add_header(k, v)
+    return resp
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self, req: Request) -> Response:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                    return to_flask_response(bolt_resp)
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                    return to_flask_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            return to_flask_response(bolt_resp)
+
+        return make_response("Not Found", 404)
+
+
+

Methods

+
+
+def handle(self, req: flask.wrappers.Request) ‑> flask.wrappers.Response +
+
+
+ +Expand source code + +
def handle(self, req: Request) -> Response:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                return to_flask_response(bolt_resp)
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                return to_flask_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        return to_flask_response(bolt_resp)
+
+    return make_response("Not Found", 404)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/flask/index.html b/docs/reference/adapter/flask/index.html new file mode 100644 index 000000000..ee765fa1e --- /dev/null +++ b/docs/reference/adapter/flask/index.html @@ -0,0 +1,151 @@ + + + + + + +slack_bolt.adapter.flask API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.flask

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.flask.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self, req: Request) -> Response:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                    return to_flask_response(bolt_resp)
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                    return to_flask_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            return to_flask_response(bolt_resp)
+
+        return make_response("Not Found", 404)
+
+
+

Methods

+
+
+def handle(self, req: flask.wrappers.Request) ‑> flask.wrappers.Response +
+
+
+ +Expand source code + +
def handle(self, req: Request) -> Response:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req))
+                return to_flask_response(bolt_resp)
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req))
+                return to_flask_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        return to_flask_response(bolt_resp)
+
+    return make_response("Not Found", 404)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/google_cloud_functions/handler.html b/docs/reference/adapter/google_cloud_functions/handler.html new file mode 100644 index 000000000..1d9b0da7f --- /dev/null +++ b/docs/reference/adapter/google_cloud_functions/handler.html @@ -0,0 +1,176 @@ + + + + + + +slack_bolt.adapter.google_cloud_functions.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.google_cloud_functions.handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class NoopLazyListenerRunner +
+
+
+ +Expand source code + +
class NoopLazyListenerRunner(LazyListenerRunner):
+    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+        raise BoltError(
+            "The google_cloud_functions adapter does not support lazy listeners. "
+            "Please consider either having a queue to pass the request to a different function or "
+            "rewriting your code not to use lazy listeners."
+        )
+
+
+

Ancestors

+ +

Inherited members

+ +
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+        # Note that lazy listener is not supported
+        self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner()
+        if self.app.oauth_flow is not None:
+            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
+
+    def handle(self, req: Request) -> Response:
+        if req.method == "GET" and self.app.oauth_flow is not None:
+            bolt_req = to_bolt_request(req)
+            if "code" in req.args or "error" in req.args or "state" in req.args:
+                bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
+                return to_flask_response(bolt_resp)
+            else:
+                bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
+                return to_flask_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            return to_flask_response(bolt_resp)
+
+        return make_response("Not Found", 404)
+
+
+

Methods

+
+
+def handle(self, req: flask.wrappers.Request) ‑> flask.wrappers.Response +
+
+
+ +Expand source code + +
def handle(self, req: Request) -> Response:
+    if req.method == "GET" and self.app.oauth_flow is not None:
+        bolt_req = to_bolt_request(req)
+        if "code" in req.args or "error" in req.args or "state" in req.args:
+            bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
+            return to_flask_response(bolt_resp)
+        else:
+            bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
+            return to_flask_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        return to_flask_response(bolt_resp)
+
+    return make_response("Not Found", 404)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/google_cloud_functions/index.html b/docs/reference/adapter/google_cloud_functions/index.html new file mode 100644 index 000000000..790d210be --- /dev/null +++ b/docs/reference/adapter/google_cloud_functions/index.html @@ -0,0 +1,153 @@ + + + + + + +slack_bolt.adapter.google_cloud_functions API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.google_cloud_functions

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.google_cloud_functions.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+        # Note that lazy listener is not supported
+        self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner()
+        if self.app.oauth_flow is not None:
+            self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
+
+    def handle(self, req: Request) -> Response:
+        if req.method == "GET" and self.app.oauth_flow is not None:
+            bolt_req = to_bolt_request(req)
+            if "code" in req.args or "error" in req.args or "state" in req.args:
+                bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
+                return to_flask_response(bolt_resp)
+            else:
+                bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
+                return to_flask_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req))
+            return to_flask_response(bolt_resp)
+
+        return make_response("Not Found", 404)
+
+
+

Methods

+
+
+def handle(self, req: flask.wrappers.Request) ‑> flask.wrappers.Response +
+
+
+ +Expand source code + +
def handle(self, req: Request) -> Response:
+    if req.method == "GET" and self.app.oauth_flow is not None:
+        bolt_req = to_bolt_request(req)
+        if "code" in req.args or "error" in req.args or "state" in req.args:
+            bolt_resp = self.app.oauth_flow.handle_callback(bolt_req)
+            return to_flask_response(bolt_resp)
+        else:
+            bolt_resp = self.app.oauth_flow.handle_installation(bolt_req)
+            return to_flask_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req))
+        return to_flask_response(bolt_resp)
+
+    return make_response("Not Found", 404)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/index.html b/docs/reference/adapter/index.html new file mode 100644 index 000000000..646c0ac81 --- /dev/null +++ b/docs/reference/adapter/index.html @@ -0,0 +1,154 @@ + + + + + + +slack_bolt.adapter API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter

+
+
+

Adapter modules for running Bolt apps along with Web frameworks or Socket Mode.

+
+
+

Sub-modules

+
+
slack_bolt.adapter.aiohttp
+
+
+
+
slack_bolt.adapter.asgi
+
+
+
+
slack_bolt.adapter.aws_lambda
+
+
+
+
slack_bolt.adapter.bottle
+
+
+
+
slack_bolt.adapter.cherrypy
+
+
+
+
slack_bolt.adapter.django
+
+
+
+
slack_bolt.adapter.falcon
+
+
+
+
slack_bolt.adapter.fastapi
+
+
+
+
slack_bolt.adapter.flask
+
+
+
+
slack_bolt.adapter.google_cloud_functions
+
+
+
+
slack_bolt.adapter.pyramid
+
+
+
+
slack_bolt.adapter.sanic
+
+
+
+
slack_bolt.adapter.socket_mode
+
+

Socket Mode adapter package provides the following implementations. If you don't have strong reasons to use 3rd party library based adapters, we …

+
+
slack_bolt.adapter.starlette
+
+
+
+
slack_bolt.adapter.tornado
+
+
+
+
slack_bolt.adapter.wsgi
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/pyramid/handler.html b/docs/reference/adapter/pyramid/handler.html new file mode 100644 index 000000000..4a4a68849 --- /dev/null +++ b/docs/reference/adapter/pyramid/handler.html @@ -0,0 +1,201 @@ + + + + + + +slack_bolt.adapter.pyramid.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.pyramid.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def to_bolt_request(request: pyramid.request.Request) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_bolt_request(request: Request) -> BoltRequest:
+    body: str = ""
+    if request.body is not None:
+        if isinstance(request.body, bytes):
+            body = request.body.decode("utf-8")
+        else:
+            body = request.body
+    bolt_req = BoltRequest(
+        body=body,
+        query=request.query_string,
+        headers=request.headers,
+    )
+    return bolt_req
+
+
+
+
+def to_pyramid_response(bolt_resp: BoltResponse) ‑> pyramid.response.Response +
+
+
+ +Expand source code + +
def to_pyramid_response(bolt_resp: BoltResponse) -> Response:
+    headers: List[Tuple[str, str]] = []
+    for k, vs in bolt_resp.headers.items():
+        for v in vs:
+            headers.append((k, v))
+
+    return Response(
+        status=bolt_resp.status,
+        body=bolt_resp.body or "",
+        headerlist=headers,
+        charset="utf-8",
+    )
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self, request: Request) -> Response:
+        if request.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if request.path == oauth_flow.install_path:
+                    bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                    bolt_resp = oauth_flow.handle_installation(bolt_req)
+                    return to_pyramid_response(bolt_resp)
+                elif request.path == oauth_flow.redirect_uri_path:
+                    bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                    bolt_resp = oauth_flow.handle_callback(bolt_req)
+                    return to_pyramid_response(bolt_resp)
+        elif request.method == "POST":
+            bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+            bolt_resp = self.app.dispatch(bolt_req)
+            return to_pyramid_response(bolt_resp)
+
+        return Response(status=404, body="Not found")
+
+
+

Methods

+
+
+def handle(self, request: pyramid.request.Request) ‑> pyramid.response.Response +
+
+
+ +Expand source code + +
def handle(self, request: Request) -> Response:
+    if request.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if request.path == oauth_flow.install_path:
+                bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                bolt_resp = oauth_flow.handle_installation(bolt_req)
+                return to_pyramid_response(bolt_resp)
+            elif request.path == oauth_flow.redirect_uri_path:
+                bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                bolt_resp = oauth_flow.handle_callback(bolt_req)
+                return to_pyramid_response(bolt_resp)
+    elif request.method == "POST":
+        bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+        bolt_resp = self.app.dispatch(bolt_req)
+        return to_pyramid_response(bolt_resp)
+
+    return Response(status=404, body="Not found")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/pyramid/index.html b/docs/reference/adapter/pyramid/index.html new file mode 100644 index 000000000..7f0903cb6 --- /dev/null +++ b/docs/reference/adapter/pyramid/index.html @@ -0,0 +1,157 @@ + + + + + + +slack_bolt.adapter.pyramid API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.pyramid

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.pyramid.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    def handle(self, request: Request) -> Response:
+        if request.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if request.path == oauth_flow.install_path:
+                    bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                    bolt_resp = oauth_flow.handle_installation(bolt_req)
+                    return to_pyramid_response(bolt_resp)
+                elif request.path == oauth_flow.redirect_uri_path:
+                    bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                    bolt_resp = oauth_flow.handle_callback(bolt_req)
+                    return to_pyramid_response(bolt_resp)
+        elif request.method == "POST":
+            bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+            bolt_resp = self.app.dispatch(bolt_req)
+            return to_pyramid_response(bolt_resp)
+
+        return Response(status=404, body="Not found")
+
+
+

Methods

+
+
+def handle(self, request: pyramid.request.Request) ‑> pyramid.response.Response +
+
+
+ +Expand source code + +
def handle(self, request: Request) -> Response:
+    if request.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if request.path == oauth_flow.install_path:
+                bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                bolt_resp = oauth_flow.handle_installation(bolt_req)
+                return to_pyramid_response(bolt_resp)
+            elif request.path == oauth_flow.redirect_uri_path:
+                bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+                bolt_resp = oauth_flow.handle_callback(bolt_req)
+                return to_pyramid_response(bolt_resp)
+    elif request.method == "POST":
+        bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request)
+        bolt_resp = self.app.dispatch(bolt_req)
+        return to_pyramid_response(bolt_resp)
+
+    return Response(status=404, body="Not found")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/sanic/async_handler.html b/docs/reference/adapter/sanic/async_handler.html new file mode 100644 index 000000000..adabe53be --- /dev/null +++ b/docs/reference/adapter/sanic/async_handler.html @@ -0,0 +1,216 @@ + + + + + + +slack_bolt.adapter.sanic.async_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.sanic.async_handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def to_async_bolt_request(req: sanic.request.types.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> AsyncBoltRequest
+
+
+
+ +Expand source code + +
def to_async_bolt_request(req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> AsyncBoltRequest:
+    request = AsyncBoltRequest(
+        body=req.body.decode("utf-8"),
+        query=req.query_string,
+        headers=req.headers,  # type: ignore[arg-type]
+    )
+
+    if addition_context_properties is not None:
+        for k, v in addition_context_properties.items():
+            request.context[k] = v
+
+    return request
+
+
+
+
+def to_sanic_response(bolt_resp: BoltResponse) ‑> sanic.response.types.HTTPResponse +
+
+
+ +Expand source code + +
def to_sanic_response(bolt_resp: BoltResponse) -> HTTPResponse:
+    resp = HTTPResponse(
+        status=bolt_resp.status,
+        body=bolt_resp.body,
+        headers=bolt_resp.first_headers_without_set_cookie(),
+    )
+
+    for cookie in bolt_resp.cookies():
+        for key, c in cookie.items():
+            expire_value = c.get("expires")
+            expires = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None
+            max_age = int(c["max-age"]) if c.get("max-age") else None
+            path = str(c.get("path")) if c.get("path") else "/"
+            domain = str(c.get("domain")) if c.get("domain") else None
+            resp.add_cookie(
+                key=key,
+                value=c.value,
+                expires=expires,
+                path=path,
+                domain=domain,
+                max_age=max_age,
+                secure=True,
+                httponly=True,
+            )
+
+    return resp
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackRequestHandler +(app: AsyncApp) +
+
+
+ +Expand source code + +
class AsyncSlackRequestHandler:
+    def __init__(self, app: AsyncApp):
+        self.app = app
+
+    async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> HTTPResponse:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(req, addition_context_properties))
+                    return to_sanic_response(bolt_resp)
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(req, addition_context_properties))
+                    return to_sanic_response(bolt_resp)
+
+        elif req.method == "POST":
+            bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, addition_context_properties))
+            return to_sanic_response(bolt_resp)
+
+        return HTTPResponse(
+            status=404,
+            body="Not found",
+        )
+
+
+

Methods

+
+
+async def handle(self,
req: sanic.request.types.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> sanic.response.types.HTTPResponse
+
+
+
+ +Expand source code + +
async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> HTTPResponse:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(req, addition_context_properties))
+                return to_sanic_response(bolt_resp)
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(req, addition_context_properties))
+                return to_sanic_response(bolt_resp)
+
+    elif req.method == "POST":
+        bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, addition_context_properties))
+        return to_sanic_response(bolt_resp)
+
+    return HTTPResponse(
+        status=404,
+        body="Not found",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/sanic/index.html b/docs/reference/adapter/sanic/index.html new file mode 100644 index 000000000..558bb321c --- /dev/null +++ b/docs/reference/adapter/sanic/index.html @@ -0,0 +1,159 @@ + + + + + + +slack_bolt.adapter.sanic API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.sanic

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.sanic.async_handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackRequestHandler +(app: AsyncApp) +
+
+
+ +Expand source code + +
class AsyncSlackRequestHandler:
+    def __init__(self, app: AsyncApp):
+        self.app = app
+
+    async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> HTTPResponse:
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+                if req.path == oauth_flow.install_path:
+                    bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(req, addition_context_properties))
+                    return to_sanic_response(bolt_resp)
+                elif req.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(req, addition_context_properties))
+                    return to_sanic_response(bolt_resp)
+
+        elif req.method == "POST":
+            bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, addition_context_properties))
+            return to_sanic_response(bolt_resp)
+
+        return HTTPResponse(
+            status=404,
+            body="Not found",
+        )
+
+
+

Methods

+
+
+async def handle(self,
req: sanic.request.types.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> sanic.response.types.HTTPResponse
+
+
+
+ +Expand source code + +
async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> HTTPResponse:
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+            if req.path == oauth_flow.install_path:
+                bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(req, addition_context_properties))
+                return to_sanic_response(bolt_resp)
+            elif req.path == oauth_flow.redirect_uri_path:
+                bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(req, addition_context_properties))
+                return to_sanic_response(bolt_resp)
+
+    elif req.method == "POST":
+        bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, addition_context_properties))
+        return to_sanic_response(bolt_resp)
+
+    return HTTPResponse(
+        status=404,
+        body="Not found",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/aiohttp/index.html b/docs/reference/adapter/socket_mode/aiohttp/index.html new file mode 100644 index 000000000..cc91a3d06 --- /dev/null +++ b/docs/reference/adapter/socket_mode/aiohttp/index.html @@ -0,0 +1,245 @@ + + + + + + +slack_bolt.adapter.socket_mode.aiohttp API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.aiohttp

+
+
+

aiohttp based implementation / asyncio compatible

+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSocketModeHandler +(app: AsyncApp,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.async_client.AsyncWebClient | None = None,
proxy: str | None = None,
ping_interval: float = 10,
loop: asyncio.events.AbstractEventLoop | None = None)
+
+
+
+ +Expand source code + +
class AsyncSocketModeHandler(AsyncBaseSocketModeHandler):
+    app: AsyncApp
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: AsyncApp,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[AsyncWebClient] = None,
+        proxy: Optional[str] = None,
+        ping_interval: float = 10,
+        loop: Optional[AbstractEventLoop] = None,
+    ):
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,
+            proxy=proxy,
+            ping_interval=ping_interval,
+            loop=loop,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = await run_async_bolt_app(self.app, req)
+        await send_async_response(client, req, bolt_resp, start)
+
+
+

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class SocketModeHandler +(app: App,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.async_client.AsyncWebClient | None = None,
proxy: str | None = None,
ping_interval: float = 10)
+
+
+
+ +Expand source code + +
class SocketModeHandler(AsyncBaseSocketModeHandler):
+    app: App
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: App,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[AsyncWebClient] = None,
+        proxy: Optional[str] = None,
+        ping_interval: float = 10,
+    ):
+        """Socket Mode adapter for Bolt apps
+
+        Args:
+            app: The Bolt app
+            app_token: App-level token starting with `xapp-`
+            logger: Custom logger
+            web_client: custom `slack_sdk.web.WebClient` instance
+            proxy: HTTP proxy URL
+            ping_interval: The ping-pong internal (seconds)
+        """
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,  # type: ignore[arg-type]
+            proxy=proxy,
+            ping_interval=ping_interval,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = run_bolt_app(self.app, req)
+        await send_async_response(client, req, bolt_resp, start)
+
+

Socket Mode adapter for Bolt apps

+

Args

+
+
app
+
The Bolt app
+
app_token
+
App-level token starting with xapp-
+
logger
+
Custom logger
+
web_client
+
custom slack_sdk.web.WebClient instance
+
proxy
+
HTTP proxy URL
+
ping_interval
+
The ping-pong internal (seconds)
+
+

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/async_base_handler.html b/docs/reference/adapter/socket_mode/async_base_handler.html new file mode 100644 index 000000000..b00420c11 --- /dev/null +++ b/docs/reference/adapter/socket_mode/async_base_handler.html @@ -0,0 +1,246 @@ + + + + + + +slack_bolt.adapter.socket_mode.async_base_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.async_base_handler

+
+
+

The base class of asyncio-based Socket Mode client implementation

+
+
+
+
+
+
+
+
+

Classes

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

Subclasses

+ +

Class variables

+
+
var appApp | AsyncApp
+
+

The type of the None singleton.

+
+
var client : slack_sdk.socket_mode.async_client.AsyncBaseSocketModeClient
+
+

The type of the None singleton.

+
+
+

Methods

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

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

+
+
+async def connect_async(self) +
+
+
+ +Expand source code + +
async def connect_async(self):
+    """Establishes a new connection with the Socket Mode server"""
+    await self.client.connect()
+
+

Establishes a new connection with the Socket Mode server

+
+
+async def disconnect_async(self) +
+
+
+ +Expand source code + +
async def disconnect_async(self):
+    """Disconnects the current WebSocket connection with the Socket Mode server"""
+    await self.client.disconnect()
+
+

Disconnects the current WebSocket connection with the Socket Mode server

+
+
+async def handle(self,
client: slack_sdk.socket_mode.async_client.AsyncBaseSocketModeClient,
req: slack_sdk.socket_mode.request.SocketModeRequest) ‑> None
+
+
+
+ +Expand source code + +
async def handle(self, client: AsyncBaseSocketModeClient, req: SocketModeRequest) -> None:
+    """Handles Socket Mode envelope requests through a WebSocket connection.
+
+    Args:
+        client: this Socket Mode client instance
+        req: the request data
+    """
+    raise NotImplementedError()
+
+

Handles Socket Mode envelope requests through a WebSocket connection.

+

Args

+
+
client
+
this Socket Mode client instance
+
req
+
the request data
+
+
+
+async def start_async(self) +
+
+
+ +Expand source code + +
async def start_async(self):
+    """Establishes a new connection and then starts infinite sleep
+    to prevent the termination of this process.
+    If you don't want to have the sleep, use `#connect()` method instead.
+    """
+    await self.connect_async()
+    if self.app.logger.level > logging.INFO:
+        print(get_boot_message())
+    else:
+        self.app.logger.info(get_boot_message())
+    await asyncio.sleep(float("inf"))
+
+

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

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/async_handler.html b/docs/reference/adapter/socket_mode/async_handler.html new file mode 100644 index 000000000..447ecf0ea --- /dev/null +++ b/docs/reference/adapter/socket_mode/async_handler.html @@ -0,0 +1,148 @@ + + + + + + +slack_bolt.adapter.socket_mode.async_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.async_handler

+
+
+

Default implementation is the aiohttp-based one.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSocketModeHandler +(app: AsyncApp,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.async_client.AsyncWebClient | None = None,
proxy: str | None = None,
ping_interval: float = 10,
loop: asyncio.events.AbstractEventLoop | None = None)
+
+
+
+ +Expand source code + +
class AsyncSocketModeHandler(AsyncBaseSocketModeHandler):
+    app: AsyncApp
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: AsyncApp,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[AsyncWebClient] = None,
+        proxy: Optional[str] = None,
+        ping_interval: float = 10,
+        loop: Optional[AbstractEventLoop] = None,
+    ):
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,
+            proxy=proxy,
+            ping_interval=ping_interval,
+            loop=loop,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = await run_async_bolt_app(self.app, req)
+        await send_async_response(client, req, bolt_resp, start)
+
+
+

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/async_internals.html b/docs/reference/adapter/socket_mode/async_internals.html new file mode 100644 index 000000000..c0b23b1de --- /dev/null +++ b/docs/reference/adapter/socket_mode/async_internals.html @@ -0,0 +1,127 @@ + + + + + + +slack_bolt.adapter.socket_mode.async_internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.async_internals

+
+
+

Internal functions

+
+
+
+
+
+
+

Functions

+
+
+async def run_async_bolt_app(app: AsyncApp,
req: slack_sdk.socket_mode.request.SocketModeRequest)
+
+
+
+ +Expand source code + +
async def run_async_bolt_app(app: AsyncApp, req: SocketModeRequest):
+    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
+
+
+
+
+async def send_async_response(client: slack_sdk.socket_mode.async_client.AsyncBaseSocketModeClient,
req: slack_sdk.socket_mode.request.SocketModeRequest,
bolt_resp: BoltResponse,
start_time: float)
+
+
+
+ +Expand source code + +
async def send_async_response(
+    client: AsyncBaseSocketModeClient,
+    req: SocketModeRequest,
+    bolt_resp: BoltResponse,
+    start_time: float,
+):
+    if bolt_resp.status == 200:
+        content_type = bolt_resp.headers.get("content-type", [""])[0]
+        if bolt_resp.body is None or len(bolt_resp.body) == 0:
+            await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
+        elif content_type.startswith("application/json"):
+            dict_body = json.loads(bolt_resp.body)
+            await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body))
+        else:
+            await client.send_socket_mode_response(
+                SocketModeResponse(
+                    envelope_id=req.envelope_id,
+                    payload={"text": bolt_resp.body},
+                )
+            )
+        if client.logger.level <= logging.DEBUG:
+            spent_time = int((time() - start_time) * 1000)
+            client.logger.debug(f"Response time: {spent_time} milliseconds")
+    else:
+        client.logger.info(f"Unsuccessful Bolt execution result (status: {bolt_resp.status}, body: {bolt_resp.body})")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/base_handler.html b/docs/reference/adapter/socket_mode/base_handler.html new file mode 100644 index 000000000..450f9ac0e --- /dev/null +++ b/docs/reference/adapter/socket_mode/base_handler.html @@ -0,0 +1,258 @@ + + + + + + +slack_bolt.adapter.socket_mode.base_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.base_handler

+
+
+

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

+
+
+
+
+
+
+
+
+

Classes

+
+
+class BaseSocketModeHandler +
+
+
+ +Expand source code + +
class BaseSocketModeHandler:
+    app: App
+    client: BaseSocketModeClient
+
+    def handle(self, client: BaseSocketModeClient, req: SocketModeRequest) -> None:
+        """Handles Socket Mode envelope requests through a WebSocket connection.
+
+        Args:
+            client: this Socket Mode client instance
+            req: the request data
+        """
+        raise NotImplementedError()
+
+    def connect(self):
+        """Establishes a new connection with the Socket Mode server"""
+        self.client.connect()
+
+    def disconnect(self):
+        """Disconnects the current WebSocket connection with the Socket Mode server"""
+        self.client.disconnect()
+
+    def close(self):
+        """Disconnects from the Socket Mode server and cleans the resources this instance holds up"""
+        self.client.close()
+
+    def start(self):
+        """Establishes a new connection and then blocks the current thread
+        to prevent the termination of this process.
+        If you don't want to block the current thread, use `#connect()` method instead.
+        """
+        self.connect()
+        if self.app.logger.level > logging.INFO:
+            print(get_boot_message())
+        else:
+            self.app.logger.info(get_boot_message())
+
+        if sys.platform == "win32":
+            # Ctrl+C etc does not work on Windows OS
+            # see https://bugs.python.org/issue35935 for details
+            signal.signal(signal.SIGINT, signal.SIG_DFL)
+
+        Event().wait()
+
+
+

Subclasses

+ +

Class variables

+
+
var appApp
+
+

The type of the None singleton.

+
+
var client : slack_sdk.socket_mode.client.BaseSocketModeClient
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def close(self) +
+
+
+ +Expand source code + +
def close(self):
+    """Disconnects from the Socket Mode server and cleans the resources this instance holds up"""
+    self.client.close()
+
+

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

+
+
+def connect(self) +
+
+
+ +Expand source code + +
def connect(self):
+    """Establishes a new connection with the Socket Mode server"""
+    self.client.connect()
+
+

Establishes a new connection with the Socket Mode server

+
+
+def disconnect(self) +
+
+
+ +Expand source code + +
def disconnect(self):
+    """Disconnects the current WebSocket connection with the Socket Mode server"""
+    self.client.disconnect()
+
+

Disconnects the current WebSocket connection with the Socket Mode server

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

Handles Socket Mode envelope requests through a WebSocket connection.

+

Args

+
+
client
+
this Socket Mode client instance
+
req
+
the request data
+
+
+
+def start(self) +
+
+
+ +Expand source code + +
def start(self):
+    """Establishes a new connection and then blocks the current thread
+    to prevent the termination of this process.
+    If you don't want to block the current thread, use `#connect()` method instead.
+    """
+    self.connect()
+    if self.app.logger.level > logging.INFO:
+        print(get_boot_message())
+    else:
+        self.app.logger.info(get_boot_message())
+
+    if sys.platform == "win32":
+        # Ctrl+C etc does not work on Windows OS
+        # see https://bugs.python.org/issue35935 for details
+        signal.signal(signal.SIGINT, signal.SIG_DFL)
+
+    Event().wait()
+
+

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

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/builtin/index.html b/docs/reference/adapter/socket_mode/builtin/index.html new file mode 100644 index 000000000..fc66eb203 --- /dev/null +++ b/docs/reference/adapter/socket_mode/builtin/index.html @@ -0,0 +1,206 @@ + + + + + + +slack_bolt.adapter.socket_mode.builtin API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.builtin

+
+
+

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

+
+
+
+
+
+
+
+
+

Classes

+
+
+class SocketModeHandler +(app: App,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.client.WebClient | None = None,
proxy: str | None = None,
proxy_headers: Dict[str, str] | None = None,
auto_reconnect_enabled: bool = True,
trace_enabled: bool = False,
all_message_trace_enabled: bool = False,
ping_pong_trace_enabled: bool = False,
ping_interval: float = 10,
receive_buffer_size: int = 1024,
concurrency: int = 10)
+
+
+
+ +Expand source code + +
class SocketModeHandler(BaseSocketModeHandler):
+    app: App
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: App,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[WebClient] = None,
+        proxy: Optional[str] = None,
+        proxy_headers: Optional[Dict[str, str]] = None,
+        auto_reconnect_enabled: bool = True,
+        trace_enabled: bool = False,
+        all_message_trace_enabled: bool = False,
+        ping_pong_trace_enabled: bool = False,
+        ping_interval: float = 10,
+        receive_buffer_size: int = 1024,
+        concurrency: int = 10,
+    ):
+        """Socket Mode adapter for Bolt apps
+
+        Args:
+            app: The Bolt app
+            app_token: App-level token starting with `xapp-`
+            logger: Custom logger
+            web_client: custom `slack_sdk.web.WebClient` instance
+            proxy: HTTP proxy URL
+            proxy_headers: Additional request header for proxy connections
+            auto_reconnect_enabled: True if the auto-reconnect logic works
+            trace_enabled: True if trace-level logging is enabled
+            all_message_trace_enabled: True if trace-logging for all received WebSocket messages is enabled
+            ping_pong_trace_enabled: True if trace-logging for all ping-pong communications
+            ping_interval: The ping-pong internal (seconds)
+            receive_buffer_size: The data length for a single socket recv operation
+            concurrency: The size of the underlying thread pool
+        """
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,
+            proxy=proxy if proxy is not None else app.client.proxy,
+            proxy_headers=proxy_headers,
+            auto_reconnect_enabled=auto_reconnect_enabled,
+            trace_enabled=trace_enabled,
+            all_message_trace_enabled=all_message_trace_enabled,
+            ping_pong_trace_enabled=ping_pong_trace_enabled,
+            ping_interval=ping_interval,
+            receive_buffer_size=receive_buffer_size,
+            concurrency=concurrency,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = run_bolt_app(self.app, req)
+        send_response(client, req, bolt_resp, start)
+
+

Socket Mode adapter for Bolt apps

+

Args

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

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/index.html b/docs/reference/adapter/socket_mode/index.html new file mode 100644 index 000000000..511ef4840 --- /dev/null +++ b/docs/reference/adapter/socket_mode/index.html @@ -0,0 +1,266 @@ + + + + + + +slack_bolt.adapter.socket_mode API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode

+
+
+

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

+ +
+
+

Sub-modules

+
+
slack_bolt.adapter.socket_mode.aiohttp
+
+

aiohttp based implementation / asyncio compatible

+
+
slack_bolt.adapter.socket_mode.async_base_handler
+
+

The base class of asyncio-based Socket Mode client implementation

+
+
slack_bolt.adapter.socket_mode.async_handler
+
+

Default implementation is the aiohttp-based one.

+
+
slack_bolt.adapter.socket_mode.async_internals
+
+

Internal functions

+
+
slack_bolt.adapter.socket_mode.base_handler
+
+

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

+
+
slack_bolt.adapter.socket_mode.builtin
+
+

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

+
+
slack_bolt.adapter.socket_mode.internals
+
+

Internal functions

+
+
slack_bolt.adapter.socket_mode.websocket_client
+
+

websocket-client based implementation

+
+
slack_bolt.adapter.socket_mode.websockets
+
+

websockets based implementation +/ asyncio compatible

+
+
+
+
+
+
+
+
+

Classes

+
+
+class SocketModeHandler +(app: App,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.client.WebClient | None = None,
proxy: str | None = None,
proxy_headers: Dict[str, str] | None = None,
auto_reconnect_enabled: bool = True,
trace_enabled: bool = False,
all_message_trace_enabled: bool = False,
ping_pong_trace_enabled: bool = False,
ping_interval: float = 10,
receive_buffer_size: int = 1024,
concurrency: int = 10)
+
+
+
+ +Expand source code + +
class SocketModeHandler(BaseSocketModeHandler):
+    app: App
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: App,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[WebClient] = None,
+        proxy: Optional[str] = None,
+        proxy_headers: Optional[Dict[str, str]] = None,
+        auto_reconnect_enabled: bool = True,
+        trace_enabled: bool = False,
+        all_message_trace_enabled: bool = False,
+        ping_pong_trace_enabled: bool = False,
+        ping_interval: float = 10,
+        receive_buffer_size: int = 1024,
+        concurrency: int = 10,
+    ):
+        """Socket Mode adapter for Bolt apps
+
+        Args:
+            app: The Bolt app
+            app_token: App-level token starting with `xapp-`
+            logger: Custom logger
+            web_client: custom `slack_sdk.web.WebClient` instance
+            proxy: HTTP proxy URL
+            proxy_headers: Additional request header for proxy connections
+            auto_reconnect_enabled: True if the auto-reconnect logic works
+            trace_enabled: True if trace-level logging is enabled
+            all_message_trace_enabled: True if trace-logging for all received WebSocket messages is enabled
+            ping_pong_trace_enabled: True if trace-logging for all ping-pong communications
+            ping_interval: The ping-pong internal (seconds)
+            receive_buffer_size: The data length for a single socket recv operation
+            concurrency: The size of the underlying thread pool
+        """
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,
+            proxy=proxy if proxy is not None else app.client.proxy,
+            proxy_headers=proxy_headers,
+            auto_reconnect_enabled=auto_reconnect_enabled,
+            trace_enabled=trace_enabled,
+            all_message_trace_enabled=all_message_trace_enabled,
+            ping_pong_trace_enabled=ping_pong_trace_enabled,
+            ping_interval=ping_interval,
+            receive_buffer_size=receive_buffer_size,
+            concurrency=concurrency,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = run_bolt_app(self.app, req)
+        send_response(client, req, bolt_resp, start)
+
+

Socket Mode adapter for Bolt apps

+

Args

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

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/internals.html b/docs/reference/adapter/socket_mode/internals.html new file mode 100644 index 000000000..ba7d2f226 --- /dev/null +++ b/docs/reference/adapter/socket_mode/internals.html @@ -0,0 +1,145 @@ + + + + + + +slack_bolt.adapter.socket_mode.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.internals

+
+
+

Internal functions

+
+
+
+
+
+
+

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)
+
+
+
+ +Expand source code + +
def run_bolt_app(app: App, req: SocketModeRequest):
+    bolt_req: BoltRequest = BoltRequest(mode="socket_mode", body=req.payload, headers=build_headers(req))
+    bolt_resp: BoltResponse = app.dispatch(bolt_req)
+    return bolt_resp
+
+
+
+
+def send_response(client: slack_sdk.socket_mode.client.BaseSocketModeClient,
req: slack_sdk.socket_mode.request.SocketModeRequest,
bolt_resp: BoltResponse,
start_time: float)
+
+
+
+ +Expand source code + +
def send_response(
+    client: BaseSocketModeClient,
+    req: SocketModeRequest,
+    bolt_resp: BoltResponse,
+    start_time: float,
+):
+    if bolt_resp.status == 200:
+        content_type = bolt_resp.headers.get("content-type", [""])[0]
+        if bolt_resp.body is None or len(bolt_resp.body) == 0:
+            client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
+        elif content_type.startswith("application/json"):
+            dict_body = json.loads(bolt_resp.body)
+            client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body))
+        else:
+            client.send_socket_mode_response(
+                SocketModeResponse(envelope_id=req.envelope_id, payload={"text": bolt_resp.body})
+            )
+
+        if client.logger.level <= logging.DEBUG:
+            spent_time = int((time() - start_time) * 1000)
+            client.logger.debug(f"Response time: {spent_time} milliseconds")
+    else:
+        client.logger.info(f"Unsuccessful Bolt execution result (status: {bolt_resp.status}, body: {bolt_resp.body})")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/websocket_client/index.html b/docs/reference/adapter/socket_mode/websocket_client/index.html new file mode 100644 index 000000000..e837ef19b --- /dev/null +++ b/docs/reference/adapter/socket_mode/websocket_client/index.html @@ -0,0 +1,196 @@ + + + + + + +slack_bolt.adapter.socket_mode.websocket_client API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.websocket_client

+
+
+

websocket-client based implementation

+
+
+
+
+
+
+
+
+

Classes

+
+
+class SocketModeHandler +(app: App,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.client.WebClient | None = None,
ping_interval: float = 10,
concurrency: int = 10,
http_proxy_host: str | None = None,
http_proxy_port: int | None = None,
http_proxy_auth: Tuple[str, str] | None = None,
proxy_type: str | None = None,
trace_enabled: bool = False)
+
+
+
+ +Expand source code + +
class SocketModeHandler(BaseSocketModeHandler):
+    app: App
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: App,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[WebClient] = None,
+        ping_interval: float = 10,
+        concurrency: int = 10,
+        http_proxy_host: Optional[str] = None,
+        http_proxy_port: Optional[int] = None,
+        http_proxy_auth: Optional[Tuple[str, str]] = None,
+        proxy_type: Optional[str] = None,
+        trace_enabled: bool = False,
+    ):
+        """Socket Mode adapter for Bolt apps
+
+        Args:
+            app: The Bolt app
+            app_token: App-level token starting with `xapp-`
+            logger: Custom logger
+            web_client: custom `slack_sdk.web.WebClient` instance
+            ping_interval: The ping-pong internal (seconds)
+            concurrency: The size of the underlying thread pool
+            http_proxy_host: HTTP proxy host
+            http_proxy_port: HTTP proxy port
+            http_proxy_auth: HTTP proxy authentication (username, password)
+            proxy_type: Proxy type
+            trace_enabled: True if trace-level logging is enabled
+        """
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,
+            ping_interval=ping_interval,
+            concurrency=concurrency,
+            http_proxy_host=http_proxy_host,
+            http_proxy_port=http_proxy_port,
+            http_proxy_auth=http_proxy_auth,
+            proxy_type=proxy_type,
+            trace_enabled=trace_enabled,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = run_bolt_app(self.app, req)
+        send_response(client, req, bolt_resp, start)
+
+

Socket Mode adapter for Bolt apps

+

Args

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

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/socket_mode/websockets/index.html b/docs/reference/adapter/socket_mode/websockets/index.html new file mode 100644 index 000000000..7f96f0021 --- /dev/null +++ b/docs/reference/adapter/socket_mode/websockets/index.html @@ -0,0 +1,245 @@ + + + + + + +slack_bolt.adapter.socket_mode.websockets API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.socket_mode.websockets

+
+
+

websockets based implementation +/ asyncio compatible

+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSocketModeHandler +(app: AsyncApp,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.async_client.AsyncWebClient | None = None,
ping_interval: float = 10)
+
+
+
+ +Expand source code + +
class AsyncSocketModeHandler(AsyncBaseSocketModeHandler):
+    app: AsyncApp
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: AsyncApp,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[AsyncWebClient] = None,
+        ping_interval: float = 10,
+    ):
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,
+            ping_interval=ping_interval,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = await run_async_bolt_app(self.app, req)
+        await send_async_response(client, req, bolt_resp, start)
+
+
+

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class SocketModeHandler +(app: App,
app_token: str | None = None,
logger: logging.Logger | None = None,
web_client: slack_sdk.web.async_client.AsyncWebClient | None = None,
ping_interval: float = 10)
+
+
+
+ +Expand source code + +
class SocketModeHandler(AsyncBaseSocketModeHandler):
+    app: App
+    app_token: str
+    client: SocketModeClient
+
+    def __init__(
+        self,
+        app: App,
+        app_token: Optional[str] = None,
+        logger: Optional[Logger] = None,
+        web_client: Optional[AsyncWebClient] = None,
+        ping_interval: float = 10,
+    ):
+        """Socket Mode adapter for Bolt apps.
+
+        Please note that this adapter does not support proxy configuration
+        as the underlying websockets module does not support proxy-wired connections.
+        If you use proxy, consider using one of the other Socket Mode adapters.
+
+        Args:
+            app: The Bolt app
+            app_token: App-level token starting with `xapp-`
+            logger: Custom logger
+            web_client: custom `slack_sdk.web.WebClient` instance
+            ping_interval: The ping-pong internal (seconds)
+        """
+        self.app = app
+        self.app_token = app_token or os.environ["SLACK_APP_TOKEN"]
+        self.client = SocketModeClient(
+            app_token=self.app_token,
+            logger=logger if logger is not None else app.logger,
+            web_client=web_client if web_client is not None else app.client,  # type: ignore[arg-type]
+            ping_interval=ping_interval,
+        )
+        self.client.socket_mode_request_listeners.append(self.handle)  # type: ignore[arg-type]
+
+    async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None:  # type: ignore[override]
+        start = time()
+        bolt_resp: BoltResponse = run_bolt_app(self.app, req)
+        await send_async_response(client, req, bolt_resp, start)
+
+

Socket Mode adapter for Bolt apps.

+

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

+

Args

+
+
app
+
The Bolt app
+
app_token
+
App-level token starting with xapp-
+
logger
+
Custom logger
+
web_client
+
custom slack_sdk.web.WebClient instance
+
ping_interval
+
The ping-pong internal (seconds)
+
+

Ancestors

+ +

Class variables

+
+
var app_token : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/starlette/async_handler.html b/docs/reference/adapter/starlette/async_handler.html new file mode 100644 index 000000000..91345eba3 --- /dev/null +++ b/docs/reference/adapter/starlette/async_handler.html @@ -0,0 +1,219 @@ + + + + + + +slack_bolt.adapter.starlette.async_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.starlette.async_handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def to_async_bolt_request(req: starlette.requests.Request,
body: bytes,
addition_context_properties: Dict[str, Any] | None = None) ‑> AsyncBoltRequest
+
+
+
+ +Expand source code + +
def to_async_bolt_request(
+    req: Request,
+    body: bytes,
+    addition_context_properties: Optional[Dict[str, Any]] = None,
+) -> AsyncBoltRequest:
+    request = AsyncBoltRequest(
+        body=body.decode("utf-8"),
+        query=req.query_params,  # type: ignore[arg-type]
+        headers=req.headers,  # type: ignore[arg-type]
+    )
+    if addition_context_properties is not None:
+        for k, v in addition_context_properties.items():
+            request.context[k] = v
+    return request
+
+
+
+
+def to_starlette_response(bolt_resp: BoltResponse) ‑> starlette.responses.Response +
+
+
+ +Expand source code + +
def to_starlette_response(bolt_resp: BoltResponse) -> Response:
+    resp = Response(
+        status_code=bolt_resp.status,
+        content=bolt_resp.body,
+        headers=bolt_resp.first_headers_without_set_cookie(),
+    )
+    for cookie in bolt_resp.cookies():
+        for name, c in cookie.items():
+            resp.set_cookie(
+                key=name,
+                value=c.value,
+                max_age=c.get("max-age"),
+                expires=c.get("expires"),
+                path=c.get("path"),
+                domain=c.get("domain"),
+                secure=True,
+                httponly=True,
+            )
+    return resp
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackRequestHandler +(app: AsyncApp) +
+
+
+ +Expand source code + +
class AsyncSlackRequestHandler:
+    def __init__(self, app: AsyncApp):
+        self.app = app
+
+    async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+        body = await req.body()
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+                if req.url.path == oauth_flow.install_path:
+                    bolt_resp = await oauth_flow.handle_installation(
+                        to_async_bolt_request(req, body, addition_context_properties)
+                    )
+                    return to_starlette_response(bolt_resp)
+                elif req.url.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = await oauth_flow.handle_callback(
+                        to_async_bolt_request(req, body, addition_context_properties)
+                    )
+                    return to_starlette_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body, addition_context_properties))
+            return to_starlette_response(bolt_resp)
+
+        return Response(
+            status_code=404,
+            content="Not found",
+        )
+
+
+

Methods

+
+
+async def handle(self,
req: starlette.requests.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> starlette.responses.Response
+
+
+
+ +Expand source code + +
async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+    body = await req.body()
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+            if req.url.path == oauth_flow.install_path:
+                bolt_resp = await oauth_flow.handle_installation(
+                    to_async_bolt_request(req, body, addition_context_properties)
+                )
+                return to_starlette_response(bolt_resp)
+            elif req.url.path == oauth_flow.redirect_uri_path:
+                bolt_resp = await oauth_flow.handle_callback(
+                    to_async_bolt_request(req, body, addition_context_properties)
+                )
+                return to_starlette_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body, addition_context_properties))
+        return to_starlette_response(bolt_resp)
+
+    return Response(
+        status_code=404,
+        content="Not found",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/starlette/handler.html b/docs/reference/adapter/starlette/handler.html new file mode 100644 index 000000000..5c74b71da --- /dev/null +++ b/docs/reference/adapter/starlette/handler.html @@ -0,0 +1,211 @@ + + + + + + +slack_bolt.adapter.starlette.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.starlette.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def to_bolt_request(req: starlette.requests.Request,
body: bytes,
addition_context_properties: Dict[str, Any] | None = None) ‑> BoltRequest
+
+
+
+ +Expand source code + +
def to_bolt_request(
+    req: Request,
+    body: bytes,
+    addition_context_properties: Optional[Dict[str, Any]] = None,
+) -> BoltRequest:
+    request = BoltRequest(
+        body=body.decode("utf-8"),
+        query=req.query_params,  # type: ignore[arg-type]
+        headers=req.headers,  # type: ignore[arg-type]
+    )
+    if addition_context_properties is not None:
+        for k, v in addition_context_properties.items():
+            request.context[k] = v
+    return request
+
+
+
+
+def to_starlette_response(bolt_resp: BoltResponse) ‑> starlette.responses.Response +
+
+
+ +Expand source code + +
def to_starlette_response(bolt_resp: BoltResponse) -> Response:
+    resp = Response(
+        status_code=bolt_resp.status,
+        content=bolt_resp.body,
+        headers=bolt_resp.first_headers_without_set_cookie(),
+    )
+    for cookie in bolt_resp.cookies():
+        for name, c in cookie.items():
+            resp.set_cookie(
+                key=name,
+                value=c.value,
+                max_age=c.get("max-age"),
+                expires=c.get("expires"),
+                path=c.get("path"),
+                domain=c.get("domain"),
+                secure=True,
+                httponly=True,
+            )
+    return resp
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+        body = await req.body()
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.url.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req, body, addition_context_properties))
+                    return to_starlette_response(bolt_resp)
+                elif req.url.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body, addition_context_properties))
+                    return to_starlette_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req, body, addition_context_properties))
+            return to_starlette_response(bolt_resp)
+
+        return Response(
+            status_code=404,
+            content="Not found",
+        )
+
+
+

Methods

+
+
+async def handle(self,
req: starlette.requests.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> starlette.responses.Response
+
+
+
+ +Expand source code + +
async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+    body = await req.body()
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.url.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req, body, addition_context_properties))
+                return to_starlette_response(bolt_resp)
+            elif req.url.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body, addition_context_properties))
+                return to_starlette_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req, body, addition_context_properties))
+        return to_starlette_response(bolt_resp)
+
+    return Response(
+        status_code=404,
+        content="Not found",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/starlette/index.html b/docs/reference/adapter/starlette/index.html new file mode 100644 index 000000000..bdf5bf42a --- /dev/null +++ b/docs/reference/adapter/starlette/index.html @@ -0,0 +1,164 @@ + + + + + + +slack_bolt.adapter.starlette API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.starlette

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.starlette.async_handler
+
+
+
+
slack_bolt.adapter.starlette.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App) +
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App):
+        self.app = app
+
+    async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+        body = await req.body()
+        if req.method == "GET":
+            if self.app.oauth_flow is not None:
+                oauth_flow: OAuthFlow = self.app.oauth_flow
+                if req.url.path == oauth_flow.install_path:
+                    bolt_resp = oauth_flow.handle_installation(to_bolt_request(req, body, addition_context_properties))
+                    return to_starlette_response(bolt_resp)
+                elif req.url.path == oauth_flow.redirect_uri_path:
+                    bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body, addition_context_properties))
+                    return to_starlette_response(bolt_resp)
+        elif req.method == "POST":
+            bolt_resp = self.app.dispatch(to_bolt_request(req, body, addition_context_properties))
+            return to_starlette_response(bolt_resp)
+
+        return Response(
+            status_code=404,
+            content="Not found",
+        )
+
+
+

Methods

+
+
+async def handle(self,
req: starlette.requests.Request,
addition_context_properties: Dict[str, Any] | None = None) ‑> starlette.responses.Response
+
+
+
+ +Expand source code + +
async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response:
+    body = await req.body()
+    if req.method == "GET":
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if req.url.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(req, body, addition_context_properties))
+                return to_starlette_response(bolt_resp)
+            elif req.url.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body, addition_context_properties))
+                return to_starlette_response(bolt_resp)
+    elif req.method == "POST":
+        bolt_resp = self.app.dispatch(to_bolt_request(req, body, addition_context_properties))
+        return to_starlette_response(bolt_resp)
+
+    return Response(
+        status_code=404,
+        content="Not found",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/tornado/async_handler.html b/docs/reference/adapter/tornado/async_handler.html new file mode 100644 index 000000000..c274429de --- /dev/null +++ b/docs/reference/adapter/tornado/async_handler.html @@ -0,0 +1,248 @@ + + + + + + +slack_bolt.adapter.tornado.async_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.tornado.async_handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def to_async_bolt_request(req: tornado.httputil.HTTPServerRequest) ‑> AsyncBoltRequest +
+
+
+ +Expand source code + +
def to_async_bolt_request(req: HTTPServerRequest) -> AsyncBoltRequest:
+    return AsyncBoltRequest(
+        body=req.body.decode("utf-8") if req.body else "",
+        query=req.query,
+        headers=req.headers,  # type: ignore[arg-type]
+    )
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackEventsHandler +(application: Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any)
+
+
+
+ +Expand source code + +
class AsyncSlackEventsHandler(RequestHandler):
+    def initialize(self, app: AsyncApp):
+        self.app = app
+
+    async def post(self):
+        bolt_resp: BoltResponse = await self.app.async_dispatch(to_async_bolt_request(self.request))
+        set_response(self, bolt_resp)
+        return
+
+

Base class for HTTP request handlers.

+

Subclasses must define at least one of the methods defined in the +"Entry points" section below.

+

Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

+

Ancestors

+
    +
  • tornado.web.RequestHandler
  • +
+

Methods

+
+
+def initialize(self,
app: AsyncApp)
+
+
+
+ +Expand source code + +
def initialize(self, app: AsyncApp):
+    self.app = app
+
+
+
+
+async def post(self) +
+
+
+ +Expand source code + +
async def post(self):
+    bolt_resp: BoltResponse = await self.app.async_dispatch(to_async_bolt_request(self.request))
+    set_response(self, bolt_resp)
+    return
+
+
+
+
+
+
+class AsyncSlackOAuthHandler +(application: Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any)
+
+
+
+ +Expand source code + +
class AsyncSlackOAuthHandler(RequestHandler):
+    def initialize(self, app: AsyncApp):
+        self.app = app
+
+    async def get(self):
+        if self.app.oauth_flow is not None:
+            oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+            if self.request.path == oauth_flow.install_path:
+                bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(self.request))
+                set_response(self, bolt_resp)
+                return
+            elif self.request.path == oauth_flow.redirect_uri_path:
+                bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(self.request))
+                set_response(self, bolt_resp)
+                return
+        self.set_status(404)
+
+

Base class for HTTP request handlers.

+

Subclasses must define at least one of the methods defined in the +"Entry points" section below.

+

Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

+

Ancestors

+
    +
  • tornado.web.RequestHandler
  • +
+

Methods

+
+
+async def get(self) +
+
+
+ +Expand source code + +
async def get(self):
+    if self.app.oauth_flow is not None:
+        oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
+        if self.request.path == oauth_flow.install_path:
+            bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(self.request))
+            set_response(self, bolt_resp)
+            return
+        elif self.request.path == oauth_flow.redirect_uri_path:
+            bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(self.request))
+            set_response(self, bolt_resp)
+            return
+    self.set_status(404)
+
+
+
+
+def initialize(self,
app: AsyncApp)
+
+
+
+ +Expand source code + +
def initialize(self, app: AsyncApp):
+    self.app = app
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/tornado/handler.html b/docs/reference/adapter/tornado/handler.html new file mode 100644 index 000000000..a69adb987 --- /dev/null +++ b/docs/reference/adapter/tornado/handler.html @@ -0,0 +1,279 @@ + + + + + + +slack_bolt.adapter.tornado.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.tornado.handler

+
+
+
+
+
+
+
+
+

Functions

+
+
+def set_response(self, bolt_resp) ‑> None +
+
+
+ +Expand source code + +
def set_response(self, bolt_resp) -> None:
+    self.set_status(bolt_resp.status)
+    self.write(bolt_resp.body)
+    for name, value in bolt_resp.first_headers_without_set_cookie().items():
+        self.set_header(name, value)
+    for cookie in bolt_resp.cookies():
+        for name, c in cookie.items():
+            expire_value = c.get("expires")
+            expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None
+            self.set_cookie(
+                name=name,
+                value=c.value,
+                max_age=c.get("max-age"),
+                expires=expire,
+                path=c.get("path"),
+                domain=c.get("domain"),
+                secure=True,
+                httponly=True,
+            )
+
+
+
+
+def to_bolt_request(req: tornado.httputil.HTTPServerRequest) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_bolt_request(req: HTTPServerRequest) -> BoltRequest:
+    return BoltRequest(
+        body=req.body.decode("utf-8") if req.body else "",
+        query=req.query,
+        headers=req.headers,  # type: ignore[arg-type]
+    )
+
+
+
+
+
+
+

Classes

+
+
+class SlackEventsHandler +(application: Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any)
+
+
+
+ +Expand source code + +
class SlackEventsHandler(RequestHandler):
+    def initialize(self, app: App):
+        self.app = app
+
+    def post(self):
+        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(self.request))
+        set_response(self, bolt_resp)
+        return
+
+

Base class for HTTP request handlers.

+

Subclasses must define at least one of the methods defined in the +"Entry points" section below.

+

Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

+

Ancestors

+
    +
  • tornado.web.RequestHandler
  • +
+

Methods

+
+
+def initialize(self,
app: App)
+
+
+
+ +Expand source code + +
def initialize(self, app: App):
+    self.app = app
+
+
+
+
+def post(self) +
+
+
+ +Expand source code + +
def post(self):
+    bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(self.request))
+    set_response(self, bolt_resp)
+    return
+
+
+
+
+
+
+class SlackOAuthHandler +(application: Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any)
+
+
+
+ +Expand source code + +
class SlackOAuthHandler(RequestHandler):
+    def initialize(self, app: App):
+        self.app = app
+
+    def get(self):
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if self.request.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(self.request))
+                set_response(self, bolt_resp)
+                return
+            elif self.request.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(self.request))
+                set_response(self, bolt_resp)
+                return
+        self.set_status(404)
+
+

Base class for HTTP request handlers.

+

Subclasses must define at least one of the methods defined in the +"Entry points" section below.

+

Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

+

Ancestors

+
    +
  • tornado.web.RequestHandler
  • +
+

Methods

+
+
+def get(self) +
+
+
+ +Expand source code + +
def get(self):
+    if self.app.oauth_flow is not None:
+        oauth_flow: OAuthFlow = self.app.oauth_flow
+        if self.request.path == oauth_flow.install_path:
+            bolt_resp = oauth_flow.handle_installation(to_bolt_request(self.request))
+            set_response(self, bolt_resp)
+            return
+        elif self.request.path == oauth_flow.redirect_uri_path:
+            bolt_resp = oauth_flow.handle_callback(to_bolt_request(self.request))
+            set_response(self, bolt_resp)
+            return
+    self.set_status(404)
+
+
+
+
+def initialize(self,
app: App)
+
+
+
+ +Expand source code + +
def initialize(self, app: App):
+    self.app = app
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/tornado/index.html b/docs/reference/adapter/tornado/index.html new file mode 100644 index 000000000..a5bec4ffb --- /dev/null +++ b/docs/reference/adapter/tornado/index.html @@ -0,0 +1,240 @@ + + + + + + +slack_bolt.adapter.tornado API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.tornado

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.tornado.async_handler
+
+
+
+
slack_bolt.adapter.tornado.handler
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackEventsHandler +(application: Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any)
+
+
+
+ +Expand source code + +
class SlackEventsHandler(RequestHandler):
+    def initialize(self, app: App):
+        self.app = app
+
+    def post(self):
+        bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(self.request))
+        set_response(self, bolt_resp)
+        return
+
+

Base class for HTTP request handlers.

+

Subclasses must define at least one of the methods defined in the +"Entry points" section below.

+

Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

+

Ancestors

+
    +
  • tornado.web.RequestHandler
  • +
+

Methods

+
+
+def initialize(self,
app: App)
+
+
+
+ +Expand source code + +
def initialize(self, app: App):
+    self.app = app
+
+
+
+
+def post(self) +
+
+
+ +Expand source code + +
def post(self):
+    bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(self.request))
+    set_response(self, bolt_resp)
+    return
+
+
+
+
+
+
+class SlackOAuthHandler +(application: Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any)
+
+
+
+ +Expand source code + +
class SlackOAuthHandler(RequestHandler):
+    def initialize(self, app: App):
+        self.app = app
+
+    def get(self):
+        if self.app.oauth_flow is not None:
+            oauth_flow: OAuthFlow = self.app.oauth_flow
+            if self.request.path == oauth_flow.install_path:
+                bolt_resp = oauth_flow.handle_installation(to_bolt_request(self.request))
+                set_response(self, bolt_resp)
+                return
+            elif self.request.path == oauth_flow.redirect_uri_path:
+                bolt_resp = oauth_flow.handle_callback(to_bolt_request(self.request))
+                set_response(self, bolt_resp)
+                return
+        self.set_status(404)
+
+

Base class for HTTP request handlers.

+

Subclasses must define at least one of the methods defined in the +"Entry points" section below.

+

Applications should not construct RequestHandler objects +directly and subclasses should not override __init__ (override +~RequestHandler.initialize instead).

+

Ancestors

+
    +
  • tornado.web.RequestHandler
  • +
+

Methods

+
+
+def get(self) +
+
+
+ +Expand source code + +
def get(self):
+    if self.app.oauth_flow is not None:
+        oauth_flow: OAuthFlow = self.app.oauth_flow
+        if self.request.path == oauth_flow.install_path:
+            bolt_resp = oauth_flow.handle_installation(to_bolt_request(self.request))
+            set_response(self, bolt_resp)
+            return
+        elif self.request.path == oauth_flow.redirect_uri_path:
+            bolt_resp = oauth_flow.handle_callback(to_bolt_request(self.request))
+            set_response(self, bolt_resp)
+            return
+    self.set_status(404)
+
+
+
+
+def initialize(self,
app: App)
+
+
+
+ +Expand source code + +
def initialize(self, app: App):
+    self.app = app
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/wsgi/handler.html b/docs/reference/adapter/wsgi/handler.html new file mode 100644 index 000000000..a6ea85ca4 --- /dev/null +++ b/docs/reference/adapter/wsgi/handler.html @@ -0,0 +1,239 @@ + + + + + + +slack_bolt.adapter.wsgi.handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.wsgi.handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App,
path: str = '/slack/events')
+
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App, path: str = "/slack/events"):
+        """Setup Bolt as a WSGI web framework, this will make your application compatible with WSGI web servers.
+        This can be used for production deployments.
+
+        With the default settings, `http://localhost:3000/slack/events`
+        Run Bolt with [gunicorn](https://gunicorn.org/)
+
+        # Python
+            app = App()
+
+            api = SlackRequestHandler(app)
+
+        # bash
+            export SLACK_SIGNING_SECRET=***
+
+            export SLACK_BOT_TOKEN=xoxb-***
+
+            gunicorn app:api -b 0.0.0.0:3000 --log-level debug
+
+        Args:
+            app: Your bolt application
+            path: The path to handle request from Slack (Default: `/slack/events`)
+        """
+        self.path = path
+        self.app = app
+
+    def dispatch(self, request: WsgiHttpRequest) -> BoltResponse:
+        return self.app.dispatch(
+            BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    def handle_installation(self, request: WsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+            BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    def handle_callback(self, request: WsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+            BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse:
+        if request.method == "GET":
+            if self.app.oauth_flow is not None:
+                if request.path == self.app.oauth_flow.install_path:
+                    bolt_response = self.handle_installation(request)
+                    return WsgiHttpResponse(
+                        status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
+                    )
+                elif request.path == self.app.oauth_flow.redirect_uri_path:
+                    bolt_response = self.handle_callback(request)
+                    return WsgiHttpResponse(
+                        status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
+                    )
+        if request.method == "POST" and request.path == self.path:
+            bolt_response = self.dispatch(request)
+            return WsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body)
+        return WsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found")
+
+    def __call__(
+        self,
+        environ: "WSGIEnvironment",
+        start_response: "StartResponse",
+    ) -> Iterable[bytes]:
+        request = WsgiHttpRequest(environ)
+        if request.protocol.startswith("HTTP"):
+            response: WsgiHttpResponse = self._get_http_response(
+                request=request,
+            )
+        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.

+

With the default settings, http://localhost:3000/slack/events +Run Bolt with gunicorn

+

Python

+
app = App()
+
+api = SlackRequestHandler(app)
+
+

bash

+
export SLACK_SIGNING_SECRET=***
+
+export SLACK_BOT_TOKEN=xoxb-***
+
+gunicorn app:api -b 0.0.0.0:3000 --log-level debug
+
+

Args

+
+
app
+
Your bolt application
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
+

Methods

+
+
+def dispatch(self,
request: WsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def dispatch(self, request: WsgiHttpRequest) -> BoltResponse:
+    return self.app.dispatch(
+        BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+    )
+
+
+
+
+def handle_callback(self,
request: WsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_callback(self, request: WsgiHttpRequest) -> BoltResponse:
+    return self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+        BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+    )
+
+
+
+
+def handle_installation(self,
request: WsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_installation(self, request: WsgiHttpRequest) -> BoltResponse:
+    return self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+        BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/wsgi/http_request.html b/docs/reference/adapter/wsgi/http_request.html new file mode 100644 index 000000000..72c5f28be --- /dev/null +++ b/docs/reference/adapter/wsgi/http_request.html @@ -0,0 +1,379 @@ + + + + + + +slack_bolt.adapter.wsgi.http_request API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.wsgi.http_request

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class WsgiHttpRequest +(environ: WSGIEnvironment) +
+
+
+ +Expand source code + +
class WsgiHttpRequest:
+    """This Class uses the PEP 3333 standard to extract request information
+    from the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("method", "path", "query_string", "protocol", "environ")
+
+    def __init__(self, environ: "WSGIEnvironment"):
+        self.method: str = environ.get("REQUEST_METHOD", "GET")
+        self.path: str = environ.get("PATH_INFO", "")
+        self.query_string: str = environ.get("QUERY_STRING", "")
+        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
+        self.environ = environ
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        headers = {}
+        for key, value in self.environ.items():
+            if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
+                name = key.lower().replace("_", "-")
+                headers[name] = value
+            if key.startswith("HTTP_"):
+                name = key[len("HTTP_"):].lower().replace("_", "-")  # fmt: skip
+                headers[name] = value
+        return headers
+
+    def get_body(self) -> str:
+        if "wsgi.input" not in self.environ:
+            return ""
+        content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
+        return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
+
+

This Class uses the PEP 3333 standard to extract request information +from the WSGI web server running the application

+

PEP 3333: https://peps.python.org/pep-3333/

+

Instance variables

+
+
var environ
+
+
+ +Expand source code + +
class WsgiHttpRequest:
+    """This Class uses the PEP 3333 standard to extract request information
+    from the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("method", "path", "query_string", "protocol", "environ")
+
+    def __init__(self, environ: "WSGIEnvironment"):
+        self.method: str = environ.get("REQUEST_METHOD", "GET")
+        self.path: str = environ.get("PATH_INFO", "")
+        self.query_string: str = environ.get("QUERY_STRING", "")
+        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
+        self.environ = environ
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        headers = {}
+        for key, value in self.environ.items():
+            if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
+                name = key.lower().replace("_", "-")
+                headers[name] = value
+            if key.startswith("HTTP_"):
+                name = key[len("HTTP_"):].lower().replace("_", "-")  # fmt: skip
+                headers[name] = value
+        return headers
+
+    def get_body(self) -> str:
+        if "wsgi.input" not in self.environ:
+            return ""
+        content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
+        return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
+
+
+
+
var method
+
+
+ +Expand source code + +
class WsgiHttpRequest:
+    """This Class uses the PEP 3333 standard to extract request information
+    from the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("method", "path", "query_string", "protocol", "environ")
+
+    def __init__(self, environ: "WSGIEnvironment"):
+        self.method: str = environ.get("REQUEST_METHOD", "GET")
+        self.path: str = environ.get("PATH_INFO", "")
+        self.query_string: str = environ.get("QUERY_STRING", "")
+        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
+        self.environ = environ
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        headers = {}
+        for key, value in self.environ.items():
+            if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
+                name = key.lower().replace("_", "-")
+                headers[name] = value
+            if key.startswith("HTTP_"):
+                name = key[len("HTTP_"):].lower().replace("_", "-")  # fmt: skip
+                headers[name] = value
+        return headers
+
+    def get_body(self) -> str:
+        if "wsgi.input" not in self.environ:
+            return ""
+        content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
+        return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
+
+
+
+
var path
+
+
+ +Expand source code + +
class WsgiHttpRequest:
+    """This Class uses the PEP 3333 standard to extract request information
+    from the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("method", "path", "query_string", "protocol", "environ")
+
+    def __init__(self, environ: "WSGIEnvironment"):
+        self.method: str = environ.get("REQUEST_METHOD", "GET")
+        self.path: str = environ.get("PATH_INFO", "")
+        self.query_string: str = environ.get("QUERY_STRING", "")
+        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
+        self.environ = environ
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        headers = {}
+        for key, value in self.environ.items():
+            if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
+                name = key.lower().replace("_", "-")
+                headers[name] = value
+            if key.startswith("HTTP_"):
+                name = key[len("HTTP_"):].lower().replace("_", "-")  # fmt: skip
+                headers[name] = value
+        return headers
+
+    def get_body(self) -> str:
+        if "wsgi.input" not in self.environ:
+            return ""
+        content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
+        return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
+
+
+
+
var protocol
+
+
+ +Expand source code + +
class WsgiHttpRequest:
+    """This Class uses the PEP 3333 standard to extract request information
+    from the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("method", "path", "query_string", "protocol", "environ")
+
+    def __init__(self, environ: "WSGIEnvironment"):
+        self.method: str = environ.get("REQUEST_METHOD", "GET")
+        self.path: str = environ.get("PATH_INFO", "")
+        self.query_string: str = environ.get("QUERY_STRING", "")
+        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
+        self.environ = environ
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        headers = {}
+        for key, value in self.environ.items():
+            if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
+                name = key.lower().replace("_", "-")
+                headers[name] = value
+            if key.startswith("HTTP_"):
+                name = key[len("HTTP_"):].lower().replace("_", "-")  # fmt: skip
+                headers[name] = value
+        return headers
+
+    def get_body(self) -> str:
+        if "wsgi.input" not in self.environ:
+            return ""
+        content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
+        return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
+
+
+
+
var query_string
+
+
+ +Expand source code + +
class WsgiHttpRequest:
+    """This Class uses the PEP 3333 standard to extract request information
+    from the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("method", "path", "query_string", "protocol", "environ")
+
+    def __init__(self, environ: "WSGIEnvironment"):
+        self.method: str = environ.get("REQUEST_METHOD", "GET")
+        self.path: str = environ.get("PATH_INFO", "")
+        self.query_string: str = environ.get("QUERY_STRING", "")
+        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
+        self.environ = environ
+
+    def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+        headers = {}
+        for key, value in self.environ.items():
+            if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
+                name = key.lower().replace("_", "-")
+                headers[name] = value
+            if key.startswith("HTTP_"):
+                name = key[len("HTTP_"):].lower().replace("_", "-")  # fmt: skip
+                headers[name] = value
+        return headers
+
+    def get_body(self) -> str:
+        if "wsgi.input" not in self.environ:
+            return ""
+        content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
+        return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
+
+
+
+
+

Methods

+
+
+def get_body(self) ‑> str +
+
+
+ +Expand source code + +
def get_body(self) -> str:
+    if "wsgi.input" not in self.environ:
+        return ""
+    content_length = int(self.environ.get("CONTENT_LENGTH") or 0)
+    return self.environ["wsgi.input"].read(content_length).decode(ENCODING)
+
+
+
+
+def get_headers(self) ‑> Dict[str, str | Sequence[str]] +
+
+
+ +Expand source code + +
def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]:
+    headers = {}
+    for key, value in self.environ.items():
+        if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}:
+            name = key.lower().replace("_", "-")
+            headers[name] = value
+        if key.startswith("HTTP_"):
+            name = key[len("HTTP_"):].lower().replace("_", "-")  # fmt: skip
+            headers[name] = value
+    return headers
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/wsgi/http_response.html b/docs/reference/adapter/wsgi/http_response.html new file mode 100644 index 000000000..726332c77 --- /dev/null +++ b/docs/reference/adapter/wsgi/http_response.html @@ -0,0 +1,200 @@ + + + + + + +slack_bolt.adapter.wsgi.http_response API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.wsgi.http_response

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class WsgiHttpResponse +(status: int, headers: Dict[str, Sequence[str]] | None = None, body: str = '') +
+
+
+ +Expand source code + +
class WsgiHttpResponse:
+    """This Class uses the PEP 3333 standard to adapt bolt response information
+    for the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("status", "_headers", "_body")
+
+    def __init__(self, status: int, headers: Optional[Dict[str, Sequence[str]]] = None, body: str = ""):
+        _status = HTTPStatus(status)
+        self.status = f"{_status.value} {_status.phrase}"
+        self._headers = headers or {}
+        self._body = bytes(body, ENCODING)
+
+    def get_headers(self) -> List[Tuple[str, str]]:
+        headers: List[Tuple[str, str]] = []
+        for key, values in self._headers.items():
+            if key.lower() == "content-length":
+                continue
+            for v in values:
+                headers.append((key, v))
+
+        headers.append(("content-length", str(len(self._body))))
+        return headers
+
+    def get_body(self) -> Iterable[bytes]:
+        return [self._body]
+
+

This Class uses the PEP 3333 standard to adapt bolt response information +for the WSGI web server running the application

+

PEP 3333: https://peps.python.org/pep-3333/

+

Instance variables

+
+
var status
+
+
+ +Expand source code + +
class WsgiHttpResponse:
+    """This Class uses the PEP 3333 standard to adapt bolt response information
+    for the WSGI web server running the application
+
+    PEP 3333: https://peps.python.org/pep-3333/
+    """
+
+    __slots__ = ("status", "_headers", "_body")
+
+    def __init__(self, status: int, headers: Optional[Dict[str, Sequence[str]]] = None, body: str = ""):
+        _status = HTTPStatus(status)
+        self.status = f"{_status.value} {_status.phrase}"
+        self._headers = headers or {}
+        self._body = bytes(body, ENCODING)
+
+    def get_headers(self) -> List[Tuple[str, str]]:
+        headers: List[Tuple[str, str]] = []
+        for key, values in self._headers.items():
+            if key.lower() == "content-length":
+                continue
+            for v in values:
+                headers.append((key, v))
+
+        headers.append(("content-length", str(len(self._body))))
+        return headers
+
+    def get_body(self) -> Iterable[bytes]:
+        return [self._body]
+
+
+
+
+

Methods

+
+
+def get_body(self) ‑> Iterable[bytes] +
+
+
+ +Expand source code + +
def get_body(self) -> Iterable[bytes]:
+    return [self._body]
+
+
+
+
+def get_headers(self) ‑> List[Tuple[str, str]] +
+
+
+ +Expand source code + +
def get_headers(self) -> List[Tuple[str, str]]:
+    headers: List[Tuple[str, str]] = []
+    for key, values in self._headers.items():
+        if key.lower() == "content-length":
+            continue
+        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 new file mode 100644 index 000000000..186d1adf6 --- /dev/null +++ b/docs/reference/adapter/wsgi/index.html @@ -0,0 +1,266 @@ + + + + + + +slack_bolt.adapter.wsgi API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.wsgi

+
+
+
+
+

Sub-modules

+
+
slack_bolt.adapter.wsgi.handler
+
+
+
+
slack_bolt.adapter.wsgi.http_request
+
+
+
+
slack_bolt.adapter.wsgi.http_response
+
+
+
+
slack_bolt.adapter.wsgi.internals
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SlackRequestHandler +(app: App,
path: str = '/slack/events')
+
+
+
+ +Expand source code + +
class SlackRequestHandler:
+    def __init__(self, app: App, path: str = "/slack/events"):
+        """Setup Bolt as a WSGI web framework, this will make your application compatible with WSGI web servers.
+        This can be used for production deployments.
+
+        With the default settings, `http://localhost:3000/slack/events`
+        Run Bolt with [gunicorn](https://gunicorn.org/)
+
+        # Python
+            app = App()
+
+            api = SlackRequestHandler(app)
+
+        # bash
+            export SLACK_SIGNING_SECRET=***
+
+            export SLACK_BOT_TOKEN=xoxb-***
+
+            gunicorn app:api -b 0.0.0.0:3000 --log-level debug
+
+        Args:
+            app: Your bolt application
+            path: The path to handle request from Slack (Default: `/slack/events`)
+        """
+        self.path = path
+        self.app = app
+
+    def dispatch(self, request: WsgiHttpRequest) -> BoltResponse:
+        return self.app.dispatch(
+            BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    def handle_installation(self, request: WsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+            BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    def handle_callback(self, request: WsgiHttpRequest) -> BoltResponse:
+        return self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+            BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+        )
+
+    def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse:
+        if request.method == "GET":
+            if self.app.oauth_flow is not None:
+                if request.path == self.app.oauth_flow.install_path:
+                    bolt_response = self.handle_installation(request)
+                    return WsgiHttpResponse(
+                        status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
+                    )
+                elif request.path == self.app.oauth_flow.redirect_uri_path:
+                    bolt_response = self.handle_callback(request)
+                    return WsgiHttpResponse(
+                        status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body
+                    )
+        if request.method == "POST" and request.path == self.path:
+            bolt_response = self.dispatch(request)
+            return WsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body)
+        return WsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found")
+
+    def __call__(
+        self,
+        environ: "WSGIEnvironment",
+        start_response: "StartResponse",
+    ) -> Iterable[bytes]:
+        request = WsgiHttpRequest(environ)
+        if request.protocol.startswith("HTTP"):
+            response: WsgiHttpResponse = self._get_http_response(
+                request=request,
+            )
+        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.

+

With the default settings, http://localhost:3000/slack/events +Run Bolt with gunicorn

+

Python

+
app = App()
+
+api = SlackRequestHandler(app)
+
+

bash

+
export SLACK_SIGNING_SECRET=***
+
+export SLACK_BOT_TOKEN=xoxb-***
+
+gunicorn app:api -b 0.0.0.0:3000 --log-level debug
+
+

Args

+
+
app
+
Your bolt application
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
+

Methods

+
+
+def dispatch(self,
request: WsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def dispatch(self, request: WsgiHttpRequest) -> BoltResponse:
+    return self.app.dispatch(
+        BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+    )
+
+
+
+
+def handle_callback(self,
request: WsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_callback(self, request: WsgiHttpRequest) -> BoltResponse:
+    return self.app.oauth_flow.handle_callback(  # type: ignore[union-attr]
+        BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+    )
+
+
+
+
+def handle_installation(self,
request: WsgiHttpRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_installation(self, request: WsgiHttpRequest) -> BoltResponse:
+    return self.app.oauth_flow.handle_installation(  # type: ignore[union-attr]
+        BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers())
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/adapter/wsgi/internals.html b/docs/reference/adapter/wsgi/internals.html new file mode 100644 index 000000000..7fdfa267f --- /dev/null +++ b/docs/reference/adapter/wsgi/internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.adapter.wsgi.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.adapter.wsgi.internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/app/app.html b/docs/reference/app/app.html new file mode 100644 index 000000000..737597548 --- /dev/null +++ b/docs/reference/app/app.html @@ -0,0 +1,3288 @@ + + + + + + +slack_bolt.app.app API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.app.app

+
+
+
+
+
+
+
+
+
+
+

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,
attaching_conversation_kwargs_enabled: bool = True)
+
+
+
+ +Expand source code + +
class App:
+    def __init__(
+        self,
+        *,
+        logger: Optional[logging.Logger] = None,
+        # Used in logger
+        name: Optional[str] = None,
+        # Set True when you run this app on a FaaS platform
+        process_before_response: bool = False,
+        # Set True if you want to handle an unhandled request as an exception
+        raise_error_for_unhandled_request: bool = False,
+        # Basic Information > Credentials > Signing Secret
+        signing_secret: Optional[str] = None,
+        # for single-workspace apps
+        token: Optional[str] = None,
+        token_verification_enabled: bool = True,
+        client: Optional[WebClient] = None,
+        # for multi-workspace apps
+        before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None,
+        authorize: Optional[Callable[..., AuthorizeResult]] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+        installation_store: Optional[InstallationStore] = None,
+        # for either only bot scope usage or v1.0.x compatibility
+        installation_store_bot_only: Optional[bool] = None,
+        # for customizing the built-in middleware
+        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,
+        # for the OAuth flow
+        oauth_settings: Optional[OAuthSettings] = None,
+        oauth_flow: Optional[OAuthFlow] = None,
+        # No need to set (the value is used only in response to ssl_check requests)
+        verification_token: Optional[str] = None,
+        # Set this one only when you want to customize the executor
+        listener_executor: Optional[Executor] = None,
+        # 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.
+
+            import os
+            from slack_bolt import App
+
+            # Initializes your app with your bot token and signing secret
+            app = App(
+                token=os.environ.get("SLACK_BOT_TOKEN"),
+                signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+            )
+
+            # Listens to incoming messages that contain "hello"
+            @app.message("hello")
+            def message_hello(message, say):
+                # say() sends a message to the channel where the event was triggered
+                say(f"Hey there <@{message['user']}>!")
+
+            # Start your app
+            if __name__ == "__main__":
+                app.start(port=int(os.environ.get("PORT", 3000)))
+
+        Refer to https://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:
+            logger: The custom logger that can be used in this app.
+            name: The application name that will be used in logging. If absent, the source file name will be used.
+            process_before_response: True if this app runs on Function as a Service. (Default: False)
+            raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
+                and use @app.error listeners instead of
+                the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+            signing_secret: The Signing Secret value used for verifying requests from Slack.
+            token: The bot/user access token required only for single-workspace app.
+            token_verification_enabled: Verifies the validity of the given token if True.
+            client: The singleton `slack_sdk.WebClient` instance for this app.
+            before_authorize: A global middleware that can be executed right before authorize function
+            authorize: The function to authorize an incoming request from Slack
+                by checking if there is a team/user in the installation data.
+            user_facing_authorize_error_message: The user-facing error message to display
+                when the app is installed but the installation is not managed by this app's installation store
+            installation_store: The module offering save/find operations of installation data
+            installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False)
+            request_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests.
+                Make sure if it's safe enough when you turn a built-in middleware off.
+                We strongly recommend using RequestVerification for better security.
+                If you have a proxy that verifies request signature in front of the Bolt app,
+                it's totally fine to disable RequestVerification to avoid duplication of work.
+                Don't turn it off just for easiness of development.
+            ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events
+                generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+            ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware.
+                `IgnoringSelfEvents` for this app's bot user message events within an assistant thread
+                This is useful for avoiding code error causing an infinite loop; Default: True
+            url_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `UrlVerification` is a built-in middleware that handles url_verification requests
+                that verify the endpoint for Events API in HTTP Mode requests.
+            attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution tokens
+                when your app receives `function_executed` or interactivity events scoped to a custom step.
+            ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True).
+                `SslCheck` is a built-in middleware that handles ssl_check requests from Slack.
+            oauth_settings: The settings related to Slack app installation flow (OAuth flow)
+            oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings.
+            verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests.
+            listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will
+                be used.
+            assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation,
+                which uses a parent message's metadata to store the latest context)
+        """
+        if signing_secret is None:
+            signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "")
+        token = token or os.environ.get("SLACK_BOT_TOKEN")
+
+        self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1]
+        self._signing_secret: str = signing_secret
+
+        self._verification_token: Optional[str] = verification_token or os.environ.get("SLACK_VERIFICATION_TOKEN", None)
+        # If a logger is explicitly passed when initializing, the logger works as the base logger.
+        # The base logger's logging settings will be propagated to all the loggers created by bolt-python.
+        self._base_logger = logger
+        # The framework logger is supposed to be used for the internal logging.
+        # Also, it's accessible via `app.logger` as the app's singleton logger.
+        self._framework_logger = logger or get_bolt_logger(App)
+        self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
+
+        self._token: Optional[str] = token
+
+        if client is not None:
+            if not isinstance(client, WebClient):
+                raise BoltError(error_client_invalid_type())
+            self._client = client
+            self._token = client.token
+            if token is not None:
+                self._framework_logger.warning(warning_client_prioritized_and_token_skipped())
+        else:
+            self._client = create_web_client(
+                # NOTE: the token here can be None
+                token=token,
+                logger=self._framework_logger,
+            )
+
+        # --------------------------------------
+        # Authorize & OAuthFlow initialization
+        # --------------------------------------
+
+        self._before_authorize: Optional[Middleware] = None
+        if before_authorize is not None:
+            if callable(before_authorize):
+                self._before_authorize = CustomMiddleware(
+                    app_name=self._name,
+                    func=before_authorize,
+                    base_logger=self._framework_logger,
+                )
+            elif isinstance(before_authorize, Middleware):
+                self._before_authorize = before_authorize
+
+        self._authorize: Optional[Authorize] = None
+        if authorize is not None:
+            if isinstance(authorize, Authorize):
+                # As long as an advanced developer understands what they're doing,
+                # bolt-python should not prevent customizing authorize middleware
+                self._authorize = authorize
+            else:
+                if oauth_settings is not None or oauth_flow is not None:
+                    # If the given authorize is a simple function,
+                    # it does not work along with installation_store.
+                    raise BoltError(error_authorize_conflicts())
+                self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize)
+
+        self._installation_store: Optional[InstallationStore] = installation_store
+        if self._installation_store is not None and self._authorize is None:
+            settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
+            self._authorize = InstallationStoreAuthorize(
+                installation_store=self._installation_store,
+                client_id=settings.client_id if settings is not None else None,
+                client_secret=settings.client_secret if settings is not None else None,
+                logger=self._framework_logger,
+                bot_only=installation_store_bot_only or False,
+                client=self._client,  # for proxy use cases etc.
+                user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"),
+            )
+
+        self._oauth_flow: Optional[OAuthFlow] = None
+
+        if (
+            oauth_settings is None
+            and os.environ.get("SLACK_CLIENT_ID") is not None
+            and os.environ.get("SLACK_CLIENT_SECRET") is not None
+        ):
+            # initialize with the default settings
+            oauth_settings = OAuthSettings()
+
+            if oauth_flow is None and installation_store is None:
+                # show info-level log for avoiding confusions
+                self._framework_logger.info(info_default_oauth_settings_loaded())
+
+        if oauth_flow is not None:
+            self._oauth_flow = oauth_flow
+            installation_store = select_consistent_installation_store(
+                client_id=self._oauth_flow.client_id,
+                app_store=self._installation_store,
+                oauth_flow_store=self._oauth_flow.settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._installation_store = installation_store
+            if installation_store is not None:
+                self._oauth_flow.settings.installation_store = installation_store
+
+            if self._oauth_flow._client is None:
+                self._oauth_flow._client = self._client
+            if self._authorize is None:
+                self._authorize = self._oauth_flow.settings.authorize
+        elif oauth_settings is not None:
+            installation_store = select_consistent_installation_store(
+                client_id=oauth_settings.client_id,
+                app_store=self._installation_store,
+                oauth_flow_store=oauth_settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._installation_store = installation_store
+            if installation_store is not None:
+                oauth_settings.installation_store = installation_store
+            self._oauth_flow = OAuthFlow(client=self.client, logger=self.logger, settings=oauth_settings)
+            if self._authorize is None:
+                self._authorize = self._oauth_flow.settings.authorize
+            self._authorize.token_rotation_expiration_minutes = oauth_settings.token_rotation_expiration_minutes  # type: ignore[attr-defined] # noqa: E501
+
+        if (self._installation_store is not None or self._authorize is not None) and self._token is not None:
+            self._token = None
+            self._framework_logger.warning(warning_token_skipped())
+
+        # after setting bot_only here, __init__ cannot replace authorize function
+        if installation_store_bot_only is not None and self._oauth_flow is not None:
+            app_bot_only = installation_store_bot_only or False
+            oauth_flow_bot_only = self._oauth_flow.settings.installation_store_bot_only
+            if app_bot_only != oauth_flow_bot_only:
+                self.logger.warning(warning_bot_only_conflicts())
+                self._oauth_flow.settings.installation_store_bot_only = app_bot_only
+                self._authorize.bot_only = app_bot_only  # type: ignore[union-attr]
+
+        self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None
+        if self._installation_store is not None:
+            self._tokens_revocation_listeners = TokenRevocationListeners(self._installation_store)
+
+        # --------------------------------------
+        # Middleware Initialization
+        # --------------------------------------
+
+        self._middleware_list: List[Middleware] = []
+        self._listeners: List[Listener] = []
+
+        if listener_executor is None:
+            listener_executor = ThreadPoolExecutor(max_workers=5)
+
+        self._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(
+            logger=self._framework_logger,
+            process_before_response=process_before_response,
+            listener_error_handler=DefaultListenerErrorHandler(logger=self._framework_logger),
+            listener_start_handler=DefaultListenerStartHandler(logger=self._framework_logger),
+            listener_completion_handler=DefaultListenerCompletionHandler(logger=self._framework_logger),
+            listener_executor=listener_executor,
+            lazy_listener_runner=ThreadLazyListenerRunner(
+                logger=self._framework_logger,
+                executor=listener_executor,
+            ),
+        )
+        self._middleware_error_handler: MiddlewareErrorHandler = DefaultMiddlewareErrorHandler(
+            logger=self._framework_logger,
+        )
+
+        self._init_middleware_list_done = False
+        self._init_middleware_list(
+            token_verification_enabled=token_verification_enabled,
+            request_verification_enabled=request_verification_enabled,
+            ignoring_self_events_enabled=ignoring_self_events_enabled,
+            ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+            ssl_check_enabled=ssl_check_enabled,
+            url_verification_enabled=url_verification_enabled,
+            attaching_function_token_enabled=attaching_function_token_enabled,
+            user_facing_authorize_error_message=user_facing_authorize_error_message,
+        )
+
+    def _init_middleware_list(
+        self,
+        token_verification_enabled: bool = True,
+        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,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        if self._init_middleware_list_done:
+            return
+        if ssl_check_enabled is True:
+            self._middleware_list.append(
+                SslCheck(
+                    verification_token=self._verification_token,
+                    base_logger=self._base_logger,
+                )
+            )
+        if request_verification_enabled is True:
+            self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger))
+
+        if self._before_authorize is not None:
+            self._middleware_list.append(self._before_authorize)
+
+        # As authorize is required for making a Bolt app function, we don't offer the flag to disable this
+        if self._oauth_flow is None:
+            if self._token is not None:
+                try:
+                    auth_test_result = None
+                    if token_verification_enabled:
+                        # This API call is for eagerly validating the token
+                        auth_test_result = self._client.auth_test(token=self._token)
+                    self._middleware_list.append(
+                        SingleTeamAuthorization(
+                            auth_test_result=auth_test_result,
+                            base_logger=self._base_logger,
+                            user_facing_authorize_error_message=user_facing_authorize_error_message,
+                        )
+                    )
+                except SlackApiError as err:
+                    raise BoltError(error_auth_test_failure(err.response))
+            elif self._authorize is not None:
+                self._middleware_list.append(
+                    MultiTeamsAuthorization(
+                        authorize=self._authorize,
+                        base_logger=self._base_logger,
+                        user_facing_authorize_error_message=user_facing_authorize_error_message,
+                    )
+                )
+            else:
+                raise BoltError(error_token_required())
+        elif self._authorize is not None:
+            self._middleware_list.append(
+                MultiTeamsAuthorization(
+                    authorize=self._authorize,
+                    base_logger=self._base_logger,
+                    user_token_resolution=self._oauth_flow.settings.user_token_resolution,
+                    user_facing_authorize_error_message=user_facing_authorize_error_message,
+                )
+            )
+        else:
+            raise BoltError(error_oauth_flow_or_authorize_required())
+
+        if ignoring_self_events_enabled is True:
+            self._middleware_list.append(
+                IgnoringSelfEvents(
+                    base_logger=self._base_logger,
+                    ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+                )
+            )
+        if url_verification_enabled is True:
+            self._middleware_list.append(UrlVerification(base_logger=self._base_logger))
+        if attaching_function_token_enabled is True:
+            self._middleware_list.append(AttachingFunctionToken())
+        self._init_middleware_list_done = True
+
+    # -------------------------
+    # accessors
+
+    @property
+    def name(self) -> str:
+        """The name of this app (default: the filename)"""
+        return self._name
+
+    @property
+    def oauth_flow(self) -> Optional[OAuthFlow]:
+        """Configured `OAuthFlow` object if exists."""
+        return self._oauth_flow
+
+    @property
+    def logger(self) -> logging.Logger:
+        """The logger this app uses."""
+        return self._framework_logger
+
+    @property
+    def client(self) -> WebClient:
+        """The singleton `slack_sdk.WebClient` instance in this app."""
+        return self._client
+
+    @property
+    def installation_store(self) -> Optional[InstallationStore]:
+        """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
+        return self._installation_store
+
+    @property
+    def listener_runner(self) -> ThreadListenerRunner:
+        """The thread executor for asynchronously running listeners."""
+        return self._listener_runner
+
+    @property
+    def process_before_response(self) -> bool:
+        return self._process_before_response or False
+
+    # -------------------------
+    # standalone server
+
+    def start(
+        self,
+        port: int = 3000,
+        path: str = "/slack/events",
+        http_server_logger_enabled: bool = True,
+    ) -> None:
+        """Starts a web server for local development.
+
+            # With the default settings, `http://localhost:3000/slack/events`
+            # is available for handling incoming requests from Slack
+            app.start()
+
+        This method internally starts a Web server process built with the `http.server` module.
+        For production, consider using a production-ready WSGI server such as Gunicorn.
+
+        Args:
+            port: The port to listen on (Default: 3000)
+            path: The path to handle request from Slack (Default: `/slack/events`)
+            http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
+        """
+        self._development_server = SlackAppDevelopmentServer(
+            port=port,
+            path=path,
+            app=self,
+            oauth_flow=self.oauth_flow,
+            http_server_logger_enabled=http_server_logger_enabled,
+        )
+        self._development_server.start()
+
+    # -------------------------
+    # main dispatcher
+
+    def dispatch(self, req: BoltRequest) -> BoltResponse:
+        """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+        Args:
+            req: An incoming request from Slack
+
+        Returns:
+            The response generated by this Bolt app
+        """
+        starting_time = time.time()
+        self._init_context(req)
+
+        resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+        middleware_state = {"next_called": False}
+
+        def middleware_next():
+            middleware_state["next_called"] = True
+
+        try:
+            for middleware in self._middleware_list:
+                middleware_state["next_called"] = False
+                if self._framework_logger.level <= logging.DEBUG:
+                    self._framework_logger.debug(debug_applying_middleware(middleware.name))
+                resp = middleware.process(req=req, resp=resp, next=middleware_next)  # type: ignore[arg-type]
+                if not middleware_state["next_called"]:
+                    if resp is None:
+                        # next() method was not called without providing the response to return to Slack
+                        # This should not be an intentional handling in usual use cases.
+                        resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                        if self._raise_error_for_unhandled_request is True:
+                            try:
+                                raise BoltUnhandledRequestError(
+                                    request=req,
+                                    current_response=resp,
+                                    last_global_middleware_name=middleware.name,
+                                )
+                            except BoltUnhandledRequestError as e:
+                                self._listener_runner.listener_error_handler.handle(
+                                    error=e,
+                                    request=req,
+                                    response=resp,
+                                )
+                            return resp
+                        self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                        return resp
+                    return resp
+
+            for listener in self._listeners:
+                listener_name = get_name_for_callable(listener.ack_function)
+                self._framework_logger.debug(debug_checking_listener(listener_name))
+                if listener.matches(req=req, resp=resp):  # type: ignore[arg-type]
+                    # run all the middleware attached to this listener first
+                    middleware_resp, next_was_not_called = listener.run_middleware(
+                        req=req, resp=resp  # type: ignore[arg-type]
+                    )
+                    if next_was_not_called:
+                        if middleware_resp is not None:
+                            if self._framework_logger.level <= logging.DEBUG:
+                                debug_message = debug_return_listener_middleware_response(
+                                    listener_name,
+                                    middleware_resp.status,
+                                    middleware_resp.body,
+                                    starting_time,
+                                )
+                                self._framework_logger.debug(debug_message)
+                            return middleware_resp
+                        # The last listener middleware didn't call next() method.
+                        # This means the listener is not for this incoming request.
+                        continue
+
+                    if middleware_resp is not None:
+                        resp = middleware_resp
+
+                    self._framework_logger.debug(debug_running_listener(listener_name))
+                    listener_response: Optional[BoltResponse] = self._listener_runner.run(
+                        request=req,
+                        response=resp,  # type: ignore[arg-type]
+                        listener_name=listener_name,
+                        listener=listener,
+                    )
+                    if listener_response is not None:
+                        return listener_response
+
+            if resp is None:
+                resp = BoltResponse(status=404, body={"error": "unhandled request"})
+            if self._raise_error_for_unhandled_request is True:
+                try:
+                    raise BoltUnhandledRequestError(
+                        request=req,
+                        current_response=resp,
+                    )
+                except BoltUnhandledRequestError as e:
+                    self._listener_runner.listener_error_handler.handle(
+                        error=e,
+                        request=req,
+                        response=resp,
+                    )
+                return resp
+            return self._handle_unmatched_requests(req, resp)
+        except Exception as error:
+            resp = BoltResponse(status=500, body="")
+            self._middleware_error_handler.handle(
+                error=error,
+                request=req,
+                response=resp,
+            )
+            return resp
+
+    def _handle_unmatched_requests(self, req: BoltRequest, resp: BoltResponse) -> BoltResponse:
+        self._framework_logger.warning(warning_unhandled_request(req))
+        return resp
+
+    # -------------------------
+    # middleware
+
+    def use(self, *args) -> Optional[Callable]:
+        """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
+
+        Refer to `App#middleware()` method's docstring for details."""
+        return self.middleware(*args)
+
+    def middleware(self, *args) -> Optional[Callable]:
+        """Registers a new middleware to this app.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.middleware
+            def middleware_func(logger, body, next):
+                logger.info(f"request body: {body}")
+                next()
+
+            # Pass a function to this method
+            app.middleware(middleware_func)
+
+        Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            *args: A function that works as a global middleware.
+        """
+        if len(args) > 0:
+            middleware_or_callable = args[0]
+            if isinstance(middleware_or_callable, Middleware):
+                middleware: Middleware = middleware_or_callable
+                self._middleware_list.append(middleware)
+                if isinstance(middleware, Assistant) and middleware.thread_context_store is not None:
+                    self._assistant_thread_context_store = middleware.thread_context_store
+            elif callable(middleware_or_callable):
+                self._middleware_list.append(
+                    CustomMiddleware(
+                        app_name=self.name,
+                        func=middleware_or_callable,
+                        base_logger=self._base_logger,
+                    )
+                )
+                return middleware_or_callable
+            else:
+                raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+        return None
+
+    # -------------------------
+    # AI Agents & Assistants
+
+    def assistant(self, assistant: Assistant) -> Optional[Callable]:
+        return self.middleware(assistant)
+
+    # -------------------------
+    # Workflows: Steps from apps
+
+    def step(
+        self,
+        callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
+        edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+        save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+        execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new step from app listener.
+
+        Unlike others, this method doesn't behave as a decorator.
+        If you want to register a step from app by a decorator, use `WorkflowStepBuilder`'s methods.
+
+            # Create a new WorkflowStep instance
+            from slack_bolt.workflows.step import WorkflowStep
+            ws = WorkflowStep(
+                callback_id="add_task",
+                edit=edit,
+                save=save,
+                execute=execute,
+            )
+            # Pass Step to set up listeners
+            app.step(ws)
+
+        Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        For further information about WorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            callback_id: The Callback ID for this step from app
+            edit: The function for displaying a modal in the Workflow Builder
+            save: The function for handling configuration in the Workflow Builder
+            execute: The function for handling the step execution
+        """
+        warnings.warn(
+            (
+                "Steps from apps for legacy workflows are now deprecated. "
+                "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+            ),
+            category=DeprecationWarning,
+        )
+        step = callback_id
+        if isinstance(callback_id, (str, Pattern)):
+            step = WorkflowStep(
+                callback_id=callback_id,
+                edit=edit,  # type: ignore[arg-type]
+                save=save,  # type: ignore[arg-type]
+                execute=execute,  # type: ignore[arg-type]
+                base_logger=self._base_logger,
+            )
+        elif isinstance(step, WorkflowStepBuilder):
+            step = step.build(base_logger=self._base_logger)
+        elif not isinstance(step, WorkflowStep):
+            raise BoltError(f"Invalid step object ({type(step)})")
+
+        self.use(WorkflowStepMiddleware(step))
+
+    # -------------------------
+    # global error handler
+
+    def error(self, func: Callable[..., Optional[BoltResponse]]) -> Callable[..., Optional[BoltResponse]]:
+        """Updates the global error handler. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.error
+            def custom_error_handler(error, body, logger):
+                logger.exception(f"Error: {error}")
+                logger.info(f"Request body: {body}")
+
+            # Pass a function to this method
+            app.error(custom_error_handler)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            func: The function that is supposed to be executed
+                when getting an unhandled error in Bolt app.
+        """
+        self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        self._middleware_error_handler = CustomMiddlewareErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        return func
+
+    # -------------------------
+    # events
+
+    def event(
+        self,
+        event: Union[
+            str,
+            Pattern,
+            Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+        ],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new event listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.event("team_join")
+            def ask_for_introduction(event, say):
+                welcome_channel_id = "C12345"
+                user_id = event["user"]
+                text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+                say(text=text, channel=welcome_channel_id)
+
+            # Pass a function to this method
+            app.event("team_join")(ask_for_introduction)
+
+        Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            event: The conditions that match a request payload.
+                If you pass a dict for this, you can have type, subtype in the constraint.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        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__
+
+    def message(
+        self,
+        keyword: Union[str, Pattern] = "",
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new message event listener. This method can be used as either a decorator or a method.
+        Check the `App#event` method's docstring for details.
+
+            # Use this method as a decorator
+            @app.message(":wave:")
+            def say_hello(message, say):
+                user = message['user']
+                say(f"Hi there, <@{user}>!")
+
+            # Pass a function to this method
+            app.message(":wave:")(say_hello)
+
+        Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            keyword: The keyword to match
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            constraints = {
+                "type": "message",
+                "subtype": (
+                    # In most cases, new message events come with no subtype.
+                    None,
+                    # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                    # By contrast, messages posted using classic app's bot token still have the subtype.
+                    "bot_message",
+                    # If an end-user posts a message with "Also send to #channel" checked,
+                    # the message event comes with this subtype.
+                    "thread_broadcast",
+                    # If an end-user posts a message with attached files,
+                    # the message event comes with this subtype.
+                    "file_share",
+                ),
+            }
+            primary_matcher = builtin_matchers.message_event(
+                keyword=keyword, constraints=constraints, base_logger=self._base_logger
+            )
+            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)
+
+        return __call__
+
+    def function(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+        auto_acknowledge: bool = True,
+        ack_timeout: int = 3,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new Function listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.function("reverse")
+            def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+                try:
+                    ack()
+                    string_to_reverse = inputs["stringToReverse"]
+                    complete(outputs={"reverseString": string_to_reverse[::-1]})
+                except Exception as e:
+                    fail(f"Cannot reverse string (error: {e})")
+                    raise e
+
+            # Pass a function to this method
+            app.function("reverse")(reverse_string)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            callback_id: The callback id to identify the function
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        if auto_acknowledge is True:
+            if ack_timeout != 3:
+                self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger)
+            return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+        return __call__
+
+    # -------------------------
+    # slash commands
+
+    def command(
+        self,
+        command: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new slash command listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.command("/echo")
+            def repeat_text(ack, say, command):
+                # Acknowledge command request
+                ack()
+                say(f"{command['text']}")
+
+            # Pass a function to this method
+            app.command("/echo")(repeat_text)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            command: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # shortcut
+
+    def shortcut(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new shortcut listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.shortcut("open_modal")
+            def open_modal(ack, body, client):
+                # Acknowledge the command request
+                ack()
+                # Call views_open with the built-in client
+                client.views_open(
+                    # Pass a valid trigger_id within 3 seconds of receiving it
+                    trigger_id=body["trigger_id"],
+                    # View payload
+                    view={ ... }
+                )
+
+            # Pass a function to this method
+            app.shortcut("open_modal")(open_modal)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.shortcut(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def global_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new global shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.global_shortcut(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def message_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new message shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.message_shortcut(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # action
+
+    def action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new action listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.action("approve_button")
+            def update_message(ack):
+                ack()
+
+            # Pass a function to this method
+            app.action("approve_button")(update_message)
+
+        * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+        * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+        * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.action(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `block_actions` action listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_action(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def attachment_action(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `interactive_message` action listener.
+        Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.attachment_action(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_submission(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_submission` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_submission(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_cancellation(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_cancellation` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_cancellation(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # view
+
+    def view(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_submission`/`view_closed` event listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.view("view_1")
+            def handle_submission(ack, body, client, view):
+                # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+                hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+                user = body["user"]["id"]
+                # Validate the inputs
+                errors = {}
+                if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                    errors["block_c"] = "The value must be longer than 5 characters"
+                if len(errors) > 0:
+                    ack(response_action="errors", errors=errors)
+                    return
+                # Acknowledge the view_submission event and close the modal
+                ack()
+                # Do whatever you want with the input data - here we're saving it to a DB
+
+            # Pass a function to this method
+            app.view("view_1")(handle_submission)
+
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_submission(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_submission` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+        details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_submission(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_closed(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_closed` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # options
+
+    def options(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new options listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.options("menu_selection")
+            def show_menu_options(ack):
+                options = [
+                    {
+                        "text": {"type": "plain_text", "text": "Option 1"},
+                        "value": "1-1",
+                    },
+                    {
+                        "text": {"type": "plain_text", "text": "Option 2"},
+                        "value": "1-2",
+                    },
+                ]
+                ack(options=options)
+
+            # Pass a function to this method
+            app.options("menu_selection")(show_menu_options)
+
+        Refer to the following documents for details:
+
+        * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+        * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.options(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_suggestion(
+        self,
+        action_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `block_suggestion` listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_suggestion(action_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_suggestion(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_suggestion` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_suggestion(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # built-in listener functions
+
+    def default_tokens_revoked_event_listener(
+        self,
+    ) -> Callable[..., Optional[BoltResponse]]:
+        if self._tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._tokens_revocation_listeners.handle_tokens_revoked_events
+
+    def default_app_uninstalled_event_listener(
+        self,
+    ) -> Callable[..., Optional[BoltResponse]]:
+        if self._tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._tokens_revocation_listeners.handle_app_uninstalled_events
+
+    def enable_token_revocation_listeners(self) -> None:
+        self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+        self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+    # -------------------------
+
+    def _init_context(self, req: BoltRequest):
+        req.context["logger"] = get_bolt_app_logger(app_name=self.name, base_logger=self._base_logger)
+        req.context["token"] = self._token
+        # Prior to version 1.15, when the token is static, self._client was passed to `req.context`.
+        # The intention was to avoid creating a new instance per request
+        # in the interest of runtime performance/memory footprint optimization.
+        # However, developers may want to replace the token held by req.context.client in some situations.
+        # In this case, this behavior can result in thread-unsafe data modification on `self._client`.
+        # (`self._client` a.k.a. `app.client` is a singleton object per an App instance)
+        # Thus, we've changed the behavior to create a new instance per request regardless of token argument
+        # in the App initialization starting v1.15.
+        # The overhead brought by this change is slight so that we believe that it is ignorable in any cases.
+        client_per_request: WebClient = WebClient(
+            token=self._token,  # this can be None, and it can be set later on
+            base_url=self._client.base_url,
+            timeout=self._client.timeout,
+            ssl=self._client.ssl,
+            proxy=self._client.proxy,
+            headers=self._client.headers,
+            team_id=req.context.team_id,
+            logger=self._client.logger,
+            retry_handlers=self._client.retry_handlers.copy() if self._client.retry_handlers is not None else None,
+        )
+        req.context["client"] = client_per_request
+
+        # Most apps do not need this "listener_runner" instance.
+        # It is intended for apps that start lazy listeners from their custom global middleware.
+        req.context["listener_runner"] = self.listener_runner
+
+    @staticmethod
+    def _to_listener_functions(
+        kwargs: dict,
+    ) -> Optional[Sequence[Callable[..., Optional[BoltResponse]]]]:
+        if kwargs:
+            functions = [kwargs["ack"]]
+            for sub in kwargs["lazy"]:
+                functions.append(sub)
+            return functions
+        return None
+
+    def _register_listener(
+        self,
+        functions: Sequence[Callable[..., Optional[BoltResponse]]],
+        primary_matcher: ListenerMatcher,
+        matchers: Optional[Sequence[Callable[..., bool]]],
+        middleware: Optional[Sequence[Union[Callable, Middleware]]],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+    ) -> Optional[Callable[..., Optional[BoltResponse]]]:
+        value_to_return = None
+        if not isinstance(functions, list):
+            functions = list(functions)
+        if len(functions) == 1:
+            # In the case where the function is registered using decorator,
+            # the registration should return the original function.
+            value_to_return = functions[0]
+
+        listener_matchers: List[ListenerMatcher] = [
+            CustomListenerMatcher(app_name=self.name, func=f, base_logger=self._base_logger) for f in (matchers or [])
+        ]
+        listener_matchers.insert(0, primary_matcher)
+        listener_middleware = []
+        for m in middleware or []:
+            if isinstance(m, Middleware):
+                listener_middleware.append(m)
+            elif callable(m):
+                listener_middleware.append(CustomMiddleware(app_name=self.name, func=m, base_logger=self._base_logger))
+            else:
+                raise ValueError(error_unexpected_listener_middleware(type(m)))
+
+        self._listeners.append(
+            CustomListener(
+                app_name=self.name,
+                ack_function=functions.pop(0),
+                lazy_functions=functions,  # type: ignore[arg-type]
+                matchers=listener_matchers,
+                middleware=listener_middleware,
+                auto_acknowledgement=auto_acknowledgement,
+                ack_timeout=ack_timeout,
+                base_logger=self._base_logger,
+            )
+        )
+        return value_to_return
+
+

Bolt App that provides functionalities to register middleware/listeners.

+
import os
+from slack_bolt import App
+
+# Initializes your app with your bot token and signing secret
+app = App(
+    token=os.environ.get("SLACK_BOT_TOKEN"),
+    signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+)
+
+# Listens to incoming messages that contain "hello"
+@app.message("hello")
+def message_hello(message, say):
+    # say() sends a message to the channel where the event was triggered
+    say(f"Hey there <@{message['user']}>!")
+
+# Start your app
+if __name__ == "__main__":
+    app.start(port=int(os.environ.get("PORT", 3000)))
+
+

Refer to https://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

+
+
logger
+
The custom logger that can be used in this app.
+
name
+
The application name that will be used in logging. If absent, the source file name will be used.
+
process_before_response
+
True if this app runs on Function as a Service. (Default: False)
+
raise_error_for_unhandled_request
+
True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+
signing_secret
+
The Signing Secret value used for verifying requests from Slack.
+
token
+
The bot/user access token required only for single-workspace app.
+
token_verification_enabled
+
Verifies the validity of the given token if True.
+
client
+
The singleton slack_sdk.WebClient instance for this app.
+
before_authorize
+
A global middleware that can be executed right before authorize function
+
authorize
+
The function to authorize an incoming request from Slack +by checking if there is a team/user in the installation data.
+
user_facing_authorize_error_message
+
The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
+
installation_store
+
The module offering save/find operations of installation data
+
installation_store_bot_only
+
Use InstallationStore#find_bot() if True (Default: False)
+
request_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +RequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
+
ignoring_self_events_enabled
+
False if you would like to disable the built-in middleware (Default: True). +IgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+
ignoring_self_assistant_message_events_enabled
+
False if you would like to disable the built-in middleware. +IgnoringSelfEvents for this app's bot user message events within an assistant thread +This is useful for avoiding code error causing an infinite loop; Default: True
+
url_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +UrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
+
attaching_function_token_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AttachingFunctionToken is a built-in middleware that injects the just-in-time workflow-execution tokens +when your app receives function_executed or interactivity events scoped to a custom step.
+
ssl_check_enabled
+
bool = False if you would like to disable the built-in middleware (Default: True). +SslCheck is a built-in middleware that handles ssl_check requests from Slack.
+
oauth_settings
+
The settings related to Slack app installation flow (OAuth flow)
+
oauth_flow
+
Instantiated OAuthFlow. This is always prioritized over oauth_settings.
+
verification_token
+
Deprecated verification mechanism. This can be used only for ssl_check requests.
+
listener_executor
+
Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will +be used.
+
assistant_thread_context_store
+
Custom AssistantThreadContext store (Default: the built-in implementation, +which uses a parent message's metadata to store the latest context)
+
+

Instance variables

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    """The singleton `slack_sdk.WebClient` instance in this app."""
+    return self._client
+
+

The singleton slack_sdk.WebClient instance in this app.

+
+
prop installation_store : slack_sdk.oauth.installation_store.installation_store.InstallationStore | None
+
+
+ +Expand source code + +
@property
+def installation_store(self) -> Optional[InstallationStore]:
+    """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
+    return self._installation_store
+
+

The slack_sdk.oauth.InstallationStore that can be used in the authorize middleware.

+
+
prop listener_runnerThreadListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> ThreadListenerRunner:
+    """The thread executor for asynchronously running listeners."""
+    return self._listener_runner
+
+

The thread executor for asynchronously running listeners.

+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> logging.Logger:
+    """The logger this app uses."""
+    return self._framework_logger
+
+

The logger this app uses.

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this app (default: the filename)"""
+    return self._name
+
+

The name of this app (default: the filename)

+
+
prop oauth_flowOAuthFlow | None
+
+
+ +Expand source code + +
@property
+def oauth_flow(self) -> Optional[OAuthFlow]:
+    """Configured `OAuthFlow` object if exists."""
+    return self._oauth_flow
+
+

Configured OAuthFlow object if exists.

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

Methods

+
+
+def action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new action listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.action("approve_button")
+        def update_message(ack):
+            ack()
+
+        # Pass a function to this method
+        app.action("approve_button")(update_message)
+
+    * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+    * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+    * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.action(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.action("approve_button")
+def update_message(ack):
+    ack()
+
+# Pass a function to this method
+app.action("approve_button")(update_message)
+
+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def assistant(self,
assistant: Assistant) ‑> Callable | None
+
+
+
+ +Expand source code + +
def assistant(self, assistant: Assistant) -> Optional[Callable]:
+    return self.middleware(assistant)
+
+
+
+
+def attachment_action(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def attachment_action(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `interactive_message` action listener.
+    Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.attachment_action(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new interactive_message action listener. +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

+
+
+def block_action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def block_action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `block_actions` action listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_action(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_actions action listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

+
+
+def block_suggestion(self,
action_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def block_suggestion(
+    self,
+    action_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `block_suggestion` listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_suggestion(action_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_suggestion listener.

+
+
+def command(self,
command: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def command(
+    self,
+    command: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new slash command listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.command("/echo")
+        def repeat_text(ack, say, command):
+            # Acknowledge command request
+            ack()
+            say(f"{command['text']}")
+
+        # Pass a function to this method
+        app.command("/echo")(repeat_text)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        command: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new slash command listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.command("/echo")
+def repeat_text(ack, say, command):
+    # Acknowledge command request
+    ack()
+    say(f"{command['text']}")
+
+# Pass a function to this method
+app.command("/echo")(repeat_text)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
command
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def default_app_uninstalled_event_listener(self) ‑> Callable[..., BoltResponse | None] +
+
+
+ +Expand source code + +
def default_app_uninstalled_event_listener(
+    self,
+) -> Callable[..., Optional[BoltResponse]]:
+    if self._tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._tokens_revocation_listeners.handle_app_uninstalled_events
+
+
+
+
+def default_tokens_revoked_event_listener(self) ‑> Callable[..., BoltResponse | None] +
+
+
+ +Expand source code + +
def default_tokens_revoked_event_listener(
+    self,
+) -> Callable[..., Optional[BoltResponse]]:
+    if self._tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._tokens_revocation_listeners.handle_tokens_revoked_events
+
+
+
+
+def dialog_cancellation(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_cancellation(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_cancellation` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_cancellation(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_cancellation listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_submission(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_submission(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_submission` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_submission(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_submission listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_suggestion(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_suggestion(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_suggestion` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_suggestion(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_suggestion listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dispatch(self,
req: BoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def dispatch(self, req: BoltRequest) -> BoltResponse:
+    """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+    Args:
+        req: An incoming request from Slack
+
+    Returns:
+        The response generated by this Bolt app
+    """
+    starting_time = time.time()
+    self._init_context(req)
+
+    resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+    middleware_state = {"next_called": False}
+
+    def middleware_next():
+        middleware_state["next_called"] = True
+
+    try:
+        for middleware in self._middleware_list:
+            middleware_state["next_called"] = False
+            if self._framework_logger.level <= logging.DEBUG:
+                self._framework_logger.debug(debug_applying_middleware(middleware.name))
+            resp = middleware.process(req=req, resp=resp, next=middleware_next)  # type: ignore[arg-type]
+            if not middleware_state["next_called"]:
+                if resp is None:
+                    # next() method was not called without providing the response to return to Slack
+                    # This should not be an intentional handling in usual use cases.
+                    resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                    if self._raise_error_for_unhandled_request is True:
+                        try:
+                            raise BoltUnhandledRequestError(
+                                request=req,
+                                current_response=resp,
+                                last_global_middleware_name=middleware.name,
+                            )
+                        except BoltUnhandledRequestError as e:
+                            self._listener_runner.listener_error_handler.handle(
+                                error=e,
+                                request=req,
+                                response=resp,
+                            )
+                        return resp
+                    self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                    return resp
+                return resp
+
+        for listener in self._listeners:
+            listener_name = get_name_for_callable(listener.ack_function)
+            self._framework_logger.debug(debug_checking_listener(listener_name))
+            if listener.matches(req=req, resp=resp):  # type: ignore[arg-type]
+                # run all the middleware attached to this listener first
+                middleware_resp, next_was_not_called = listener.run_middleware(
+                    req=req, resp=resp  # type: ignore[arg-type]
+                )
+                if next_was_not_called:
+                    if middleware_resp is not None:
+                        if self._framework_logger.level <= logging.DEBUG:
+                            debug_message = debug_return_listener_middleware_response(
+                                listener_name,
+                                middleware_resp.status,
+                                middleware_resp.body,
+                                starting_time,
+                            )
+                            self._framework_logger.debug(debug_message)
+                        return middleware_resp
+                    # The last listener middleware didn't call next() method.
+                    # This means the listener is not for this incoming request.
+                    continue
+
+                if middleware_resp is not None:
+                    resp = middleware_resp
+
+                self._framework_logger.debug(debug_running_listener(listener_name))
+                listener_response: Optional[BoltResponse] = self._listener_runner.run(
+                    request=req,
+                    response=resp,  # type: ignore[arg-type]
+                    listener_name=listener_name,
+                    listener=listener,
+                )
+                if listener_response is not None:
+                    return listener_response
+
+        if resp is None:
+            resp = BoltResponse(status=404, body={"error": "unhandled request"})
+        if self._raise_error_for_unhandled_request is True:
+            try:
+                raise BoltUnhandledRequestError(
+                    request=req,
+                    current_response=resp,
+                )
+            except BoltUnhandledRequestError as e:
+                self._listener_runner.listener_error_handler.handle(
+                    error=e,
+                    request=req,
+                    response=resp,
+                )
+            return resp
+        return self._handle_unmatched_requests(req, resp)
+    except Exception as error:
+        resp = BoltResponse(status=500, body="")
+        self._middleware_error_handler.handle(
+            error=error,
+            request=req,
+            response=resp,
+        )
+        return resp
+
+

Applies all middleware and dispatches an incoming request from Slack to the right code path.

+

Args

+
+
req
+
An incoming request from Slack
+
+

Returns

+

The response generated by this Bolt app

+
+
+def enable_token_revocation_listeners(self) ‑> None +
+
+
+ +Expand source code + +
def enable_token_revocation_listeners(self) -> None:
+    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+
+
+
+def error(self,
func: Callable[..., BoltResponse | None]) ‑> Callable[..., BoltResponse | None]
+
+
+
+ +Expand source code + +
def error(self, func: Callable[..., Optional[BoltResponse]]) -> Callable[..., Optional[BoltResponse]]:
+    """Updates the global error handler. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.error
+        def custom_error_handler(error, body, logger):
+            logger.exception(f"Error: {error}")
+            logger.info(f"Request body: {body}")
+
+        # Pass a function to this method
+        app.error(custom_error_handler)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        func: The function that is supposed to be executed
+            when getting an unhandled error in Bolt app.
+    """
+    self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    self._middleware_error_handler = CustomMiddlewareErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    return func
+
+

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

+
# Use this method as a decorator
+@app.error
+def custom_error_handler(error, body, logger):
+    logger.exception(f"Error: {error}")
+    logger.info(f"Request body: {body}")
+
+# Pass a function to this method
+app.error(custom_error_handler)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
func
+
The function that is supposed to be executed +when getting an unhandled error in Bolt app.
+
+
+
+def event(self,
event: str | Pattern | Dict[str, str | Sequence[str | Pattern | None] | None],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def event(
+    self,
+    event: Union[
+        str,
+        Pattern,
+        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+    ],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new event listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.event("team_join")
+        def ask_for_introduction(event, say):
+            welcome_channel_id = "C12345"
+            user_id = event["user"]
+            text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+            say(text=text, channel=welcome_channel_id)
+
+        # Pass a function to this method
+        app.event("team_join")(ask_for_introduction)
+
+    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        event: The conditions that match a request payload.
+            If you pass a dict for this, you can have type, subtype in the constraint.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    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__
+
+

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

+
# Use this method as a decorator
+@app.event("team_join")
+def ask_for_introduction(event, say):
+    welcome_channel_id = "C12345"
+    user_id = event["user"]
+    text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+    say(text=text, channel=welcome_channel_id)
+
+# Pass a function to this method
+app.event("team_join")(ask_for_introduction)
+
+

Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
event
+
The conditions that match a request payload. +If you pass a dict for this, you can have type, subtype in the constraint.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def function(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None,
auto_acknowledge: bool = True,
ack_timeout: int = 3) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def function(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    auto_acknowledge: bool = True,
+    ack_timeout: int = 3,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new Function listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.function("reverse")
+        def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+            try:
+                ack()
+                string_to_reverse = inputs["stringToReverse"]
+                complete(outputs={"reverseString": string_to_reverse[::-1]})
+            except Exception as e:
+                fail(f"Cannot reverse string (error: {e})")
+                raise e
+
+        # Pass a function to this method
+        app.function("reverse")(reverse_string)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        callback_id: The callback id to identify the function
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    if auto_acknowledge is True:
+        if ack_timeout != 3:
+            self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger)
+        return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.function("reverse")
+def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+    try:
+        ack()
+        string_to_reverse = inputs["stringToReverse"]
+        complete(outputs={"reverseString": string_to_reverse[::-1]})
+    except Exception as e:
+        fail(f"Cannot reverse string (error: {e})")
+        raise e
+
+# Pass a function to this method
+app.function("reverse")(reverse_string)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
callback_id
+
The callback id to identify the function
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def global_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def global_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new global shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.global_shortcut(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new global shortcut listener.

+
+
+def message(self,
keyword: str | Pattern = '',
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def message(
+    self,
+    keyword: Union[str, Pattern] = "",
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new message event listener. This method can be used as either a decorator or a method.
+    Check the `App#event` method's docstring for details.
+
+        # Use this method as a decorator
+        @app.message(":wave:")
+        def say_hello(message, say):
+            user = message['user']
+            say(f"Hi there, <@{user}>!")
+
+        # Pass a function to this method
+        app.message(":wave:")(say_hello)
+
+    Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        keyword: The keyword to match
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        constraints = {
+            "type": "message",
+            "subtype": (
+                # In most cases, new message events come with no subtype.
+                None,
+                # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                # By contrast, messages posted using classic app's bot token still have the subtype.
+                "bot_message",
+                # If an end-user posts a message with "Also send to #channel" checked,
+                # the message event comes with this subtype.
+                "thread_broadcast",
+                # If an end-user posts a message with attached files,
+                # the message event comes with this subtype.
+                "file_share",
+            ),
+        }
+        primary_matcher = builtin_matchers.message_event(
+            keyword=keyword, constraints=constraints, base_logger=self._base_logger
+        )
+        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)
+
+    return __call__
+
+

Registers a new message event listener. This method can be used as either a decorator or a method. +Check the App#event method's docstring for details.

+
# Use this method as a decorator
+@app.message(":wave:")
+def say_hello(message, say):
+    user = message['user']
+    say(f"Hi there, <@{user}>!")
+
+# Pass a function to this method
+app.message(":wave:")(say_hello)
+
+

Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
keyword
+
The keyword to match
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def message_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def message_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new message shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.message_shortcut(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new message shortcut listener.

+
+
+def middleware(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def middleware(self, *args) -> Optional[Callable]:
+    """Registers a new middleware to this app.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.middleware
+        def middleware_func(logger, body, next):
+            logger.info(f"request body: {body}")
+            next()
+
+        # Pass a function to this method
+        app.middleware(middleware_func)
+
+    Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        *args: A function that works as a global middleware.
+    """
+    if len(args) > 0:
+        middleware_or_callable = args[0]
+        if isinstance(middleware_or_callable, Middleware):
+            middleware: Middleware = middleware_or_callable
+            self._middleware_list.append(middleware)
+            if isinstance(middleware, Assistant) and middleware.thread_context_store is not None:
+                self._assistant_thread_context_store = middleware.thread_context_store
+        elif callable(middleware_or_callable):
+            self._middleware_list.append(
+                CustomMiddleware(
+                    app_name=self.name,
+                    func=middleware_or_callable,
+                    base_logger=self._base_logger,
+                )
+            )
+            return middleware_or_callable
+        else:
+            raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+    return None
+
+

Registers a new middleware to this app. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.middleware
+def middleware_func(logger, body, next):
+    logger.info(f"request body: {body}")
+    next()
+
+# Pass a function to this method
+app.middleware(middleware_func)
+
+

Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
*args
+
A function that works as a global middleware.
+
+
+
+def options(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def options(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new options listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.options("menu_selection")
+        def show_menu_options(ack):
+            options = [
+                {
+                    "text": {"type": "plain_text", "text": "Option 1"},
+                    "value": "1-1",
+                },
+                {
+                    "text": {"type": "plain_text", "text": "Option 2"},
+                    "value": "1-2",
+                },
+            ]
+            ack(options=options)
+
+        # Pass a function to this method
+        app.options("menu_selection")(show_menu_options)
+
+    Refer to the following documents for details:
+
+    * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+    * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.options(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.options("menu_selection")
+def show_menu_options(ack):
+    options = [
+        {
+            "text": {"type": "plain_text", "text": "Option 1"},
+            "value": "1-1",
+        },
+        {
+            "text": {"type": "plain_text", "text": "Option 2"},
+            "value": "1-2",
+        },
+    ]
+    ack(options=options)
+
+# Pass a function to this method
+app.options("menu_selection")(show_menu_options)
+
+

Refer to the following documents for details:

+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def shortcut(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def shortcut(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new shortcut listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.shortcut("open_modal")
+        def open_modal(ack, body, client):
+            # Acknowledge the command request
+            ack()
+            # Call views_open with the built-in client
+            client.views_open(
+                # Pass a valid trigger_id within 3 seconds of receiving it
+                trigger_id=body["trigger_id"],
+                # View payload
+                view={ ... }
+            )
+
+        # Pass a function to this method
+        app.shortcut("open_modal")(open_modal)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.shortcut(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.shortcut("open_modal")
+def open_modal(ack, body, client):
+    # Acknowledge the command request
+    ack()
+    # Call views_open with the built-in client
+    client.views_open(
+        # Pass a valid trigger_id within 3 seconds of receiving it
+        trigger_id=body["trigger_id"],
+        # View payload
+        view={ ... }
+    )
+
+# Pass a function to this method
+app.shortcut("open_modal")(open_modal)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def start(self,
port: int = 3000,
path: str = '/slack/events',
http_server_logger_enabled: bool = True) ‑> None
+
+
+
+ +Expand source code + +
def start(
+    self,
+    port: int = 3000,
+    path: str = "/slack/events",
+    http_server_logger_enabled: bool = True,
+) -> None:
+    """Starts a web server for local development.
+
+        # With the default settings, `http://localhost:3000/slack/events`
+        # is available for handling incoming requests from Slack
+        app.start()
+
+    This method internally starts a Web server process built with the `http.server` module.
+    For production, consider using a production-ready WSGI server such as Gunicorn.
+
+    Args:
+        port: The port to listen on (Default: 3000)
+        path: The path to handle request from Slack (Default: `/slack/events`)
+        http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
+    """
+    self._development_server = SlackAppDevelopmentServer(
+        port=port,
+        path=path,
+        app=self,
+        oauth_flow=self.oauth_flow,
+        http_server_logger_enabled=http_server_logger_enabled,
+    )
+    self._development_server.start()
+
+

Starts a web server for local development.

+
# With the default settings, `http://localhost:3000/slack/events`
+# is available for handling incoming requests from Slack
+app.start()
+
+

This method internally starts a Web server process built with the http.server module. +For production, consider using a production-ready WSGI server such as Gunicorn.

+

Args

+
+
port
+
The port to listen on (Default: 3000)
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
http_server_logger_enabled
+
The flag to enable http.server logging if True (Default: True)
+
+
+
+def step(self,
callback_id: str | Pattern | WorkflowStep | WorkflowStepBuilder,
edit: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None,
save: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None,
execute: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None)
+
+
+
+ +Expand source code + +
def step(
+    self,
+    callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
+    edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new step from app listener.
+
+    Unlike others, this method doesn't behave as a decorator.
+    If you want to register a step from app by a decorator, use `WorkflowStepBuilder`'s methods.
+
+        # Create a new WorkflowStep instance
+        from slack_bolt.workflows.step import WorkflowStep
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        # Pass Step to set up listeners
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    For further information about WorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        callback_id: The Callback ID for this step from app
+        edit: The function for displaying a modal in the Workflow Builder
+        save: The function for handling configuration in the Workflow Builder
+        execute: The function for handling the step execution
+    """
+    warnings.warn(
+        (
+            "Steps from apps for legacy workflows are now deprecated. "
+            "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+        ),
+        category=DeprecationWarning,
+    )
+    step = callback_id
+    if isinstance(callback_id, (str, Pattern)):
+        step = WorkflowStep(
+            callback_id=callback_id,
+            edit=edit,  # type: ignore[arg-type]
+            save=save,  # type: ignore[arg-type]
+            execute=execute,  # type: ignore[arg-type]
+            base_logger=self._base_logger,
+        )
+    elif isinstance(step, WorkflowStepBuilder):
+        step = step.build(base_logger=self._base_logger)
+    elif not isinstance(step, WorkflowStep):
+        raise BoltError(f"Invalid step object ({type(step)})")
+
+    self.use(WorkflowStepMiddleware(step))
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new step from app listener.

+

Unlike others, this method doesn't behave as a decorator. +If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

+
# Create a new WorkflowStep instance
+from slack_bolt.workflows.step import WorkflowStep
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+# Pass Step to set up listeners
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
callback_id
+
The Callback ID for this step from app
+
edit
+
The function for displaying a modal in the Workflow Builder
+
save
+
The function for handling configuration in the Workflow Builder
+
execute
+
The function for handling the step execution
+
+
+
+def use(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def use(self, *args) -> Optional[Callable]:
+    """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
+
+    Refer to `App#middleware()` method's docstring for details."""
+    return self.middleware(*args)
+
+

Registers a new global middleware to this app. This method can be used as either a decorator or a method.

+

Refer to App#middleware() method's docstring for details.

+
+
+def view(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_submission`/`view_closed` event listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.view("view_1")
+        def handle_submission(ack, body, client, view):
+            # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+            hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+            user = body["user"]["id"]
+            # Validate the inputs
+            errors = {}
+            if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                errors["block_c"] = "The value must be longer than 5 characters"
+            if len(errors) > 0:
+                ack(response_action="errors", errors=errors)
+                return
+            # Acknowledge the view_submission event and close the modal
+            ack()
+            # Do whatever you want with the input data - here we're saving it to a DB
+
+        # Pass a function to this method
+        app.view("view_1")(handle_submission)
+
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission/view_closed event listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.view("view_1")
+def handle_submission(ack, body, client, view):
+    # Assume there's an input block with <code>block\_c</code> as the block_id and <code>dreamy\_input</code>
+    hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+    user = body["user"]["id"]
+    # Validate the inputs
+    errors = {}
+    if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+        errors["block_c"] = "The value must be longer than 5 characters"
+    if len(errors) > 0:
+        ack(response_action="errors", errors=errors)
+        return
+    # Acknowledge the view_submission event and close the modal
+    ack()
+    # Do whatever you want with the input data - here we're saving it to a DB
+
+# Pass a function to this method
+app.view("view_1")(handle_submission)
+
+

Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def view_closed(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view_closed(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_closed` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+ +
+
+def view_submission(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view_submission(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_submission` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+    details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_submission(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

+
+
+
+
+class SlackAppDevelopmentServer +(port: int,
path: str,
app: App,
oauth_flow: OAuthFlow | None = None,
http_server_logger_enabled: bool = True)
+
+
+
+ +Expand source code + +
class SlackAppDevelopmentServer:
+    def __init__(
+        self,
+        port: int,
+        path: str,
+        app: App,
+        oauth_flow: Optional[OAuthFlow] = None,
+        http_server_logger_enabled: bool = True,
+    ):
+        """Slack App Development Server
+
+        This is a thin wrapper of http.server.HTTPServer and is good enough
+        for your local development or prototyping.
+
+        However, as mentioned in Python official documents, using http.server module in production
+        is not recommended. Please consider using an adapter (refer to slack_bolt.adapter.*)
+        along with a production-grade server when running the app for end users.
+        https://docs.python.org/3/library/http.server.html#http.server.HTTPServer
+
+        Args:
+            port: the port number
+            path: the path to receive incoming requests
+            app: the `App` instance to execute
+            oauth_flow: the `OAuthFlow` instance to use for OAuth flow
+            http_server_logger_enabled: The flag to turn on/off http.server's logging
+        """
+        self._port: int = port
+        self._bolt_endpoint_path: str = path
+        self._bolt_app: App = app
+        self._bolt_oauth_flow: Optional[OAuthFlow] = oauth_flow
+        self._http_server_logger_enabled = http_server_logger_enabled
+
+        _port: int = self._port
+        _bolt_endpoint_path: str = self._bolt_endpoint_path
+        _bolt_app: App = self._bolt_app
+        _bolt_oauth_flow: Optional[OAuthFlow] = self._bolt_oauth_flow
+        _http_server_logger_enabled = self._http_server_logger_enabled
+
+        class SlackAppHandler(SimpleHTTPRequestHandler):
+            def log_message(self, format: str, *args: Any) -> None:
+                if _http_server_logger_enabled is True:
+                    super().log_message(format, *args)
+
+            def do_GET(self):
+                if _bolt_oauth_flow:
+                    request_path, _, query = self.path.partition("?")
+                    if request_path == _bolt_oauth_flow.install_path:
+                        bolt_req = BoltRequest(
+                            body="",
+                            query=query,
+                            # email.message.Message's mapping interface is dict compatible
+                            headers=self.headers,
+                        )
+                        bolt_resp = _bolt_oauth_flow.handle_installation(bolt_req)
+                        self._send_bolt_response(bolt_resp)
+                    elif request_path == _bolt_oauth_flow.redirect_uri_path:
+                        bolt_req = BoltRequest(
+                            body="",
+                            query=query,
+                            # email.message.Message's mapping interface is dict compatible
+                            headers=self.headers,
+                        )
+                        bolt_resp = _bolt_oauth_flow.handle_callback(bolt_req)
+                        self._send_bolt_response(bolt_resp)
+                    else:
+                        self._send_response(404, headers={})
+                else:
+                    self._send_response(404, headers={})
+
+            def do_POST(self):
+                request_path, _, query = self.path.partition("?")
+                if _bolt_endpoint_path != request_path:
+                    self._send_response(404, headers={})
+                    return
+
+                len_header = self.headers.get("Content-Length") or 0
+                request_body = self.rfile.read(int(len_header)).decode("utf-8")
+                bolt_req = BoltRequest(
+                    body=request_body,
+                    query=query,
+                    # email.message.Message's mapping interface is dict compatible
+                    headers=self.headers,
+                )
+                bolt_resp: BoltResponse = _bolt_app.dispatch(bolt_req)
+                self._send_bolt_response(bolt_resp)
+
+            def _send_bolt_response(self, bolt_resp: BoltResponse):
+                self._send_response(
+                    status=bolt_resp.status,
+                    headers=bolt_resp.headers,
+                    body=bolt_resp.body,
+                )
+
+            def _send_response(
+                self,
+                status: int,
+                headers: Dict[str, Sequence[str]],
+                body: Union[str, dict] = "",
+            ):
+                self.send_response(status)
+
+                response_body = body if isinstance(body, str) else json.dumps(body)
+                body_bytes = response_body.encode("utf-8")
+
+                for k, vs in headers.items():
+                    for v in vs:
+                        self.send_header(k, v)
+                self.send_header("Content-Length", str(len(body_bytes)))
+                self.end_headers()
+                self.wfile.write(body_bytes)
+
+        self._server = HTTPServer(("0.0.0.0", self._port), SlackAppHandler)
+
+    def start(self) -> None:
+        """Starts a new web server process."""
+        if self._bolt_app.logger.level > logging.INFO:
+            print(get_boot_message(development_server=True))
+        else:
+            self._bolt_app.logger.info(get_boot_message(development_server=True))
+
+        try:
+            self._server.serve_forever(0.05)
+        finally:
+            self._server.server_close()
+
+

Slack App Development Server

+

This is a thin wrapper of http.server.HTTPServer and is good enough +for your local development or prototyping.

+

However, as mentioned in Python official documents, using http.server module in production +is not recommended. Please consider using an adapter (refer to slack_bolt.adapter.*) +along with a production-grade server when running the app for end users. +https://docs.python.org/3/library/http.server.html#http.server.HTTPServer

+

Args

+
+
port
+
the port number
+
path
+
the path to receive incoming requests
+
app
+
the App instance to execute
+
oauth_flow
+
the OAuthFlow instance to use for OAuth flow
+
http_server_logger_enabled
+
The flag to turn on/off http.server's logging
+
+

Methods

+
+
+def start(self) ‑> None +
+
+
+ +Expand source code + +
def start(self) -> None:
+    """Starts a new web server process."""
+    if self._bolt_app.logger.level > logging.INFO:
+        print(get_boot_message(development_server=True))
+    else:
+        self._bolt_app.logger.info(get_boot_message(development_server=True))
+
+    try:
+        self._server.serve_forever(0.05)
+    finally:
+        self._server.server_close()
+
+

Starts a new web server process.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/app/async_app.html b/docs/reference/app/async_app.html new file mode 100644 index 000000000..cf4c651cb --- /dev/null +++ b/docs/reference/app/async_app.html @@ -0,0 +1,3214 @@ + + + + + + +slack_bolt.app.async_app API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.app.async_app

+
+
+
+
+
+
+
+
+
+
+

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,
attaching_conversation_kwargs_enabled: bool = True)
+
+
+
+ +Expand source code + +
class AsyncApp:
+    def __init__(
+        self,
+        *,
+        logger: Optional[logging.Logger] = None,
+        # Used in logger
+        name: Optional[str] = None,
+        # Set True when you run this app on a FaaS platform
+        process_before_response: bool = False,
+        # Set True if you want to handle an unhandled request as an exception
+        raise_error_for_unhandled_request: bool = False,
+        # Basic Information > Credentials > Signing Secret
+        signing_secret: Optional[str] = None,
+        # for single-workspace apps
+        token: Optional[str] = None,
+        client: Optional[AsyncWebClient] = None,
+        # for multi-workspace apps
+        before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None,
+        authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+        installation_store: Optional[AsyncInstallationStore] = None,
+        # for either only bot scope usage or v1.0.x compatibility
+        installation_store_bot_only: Optional[bool] = None,
+        # for customizing the built-in middleware
+        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,
+        # for the OAuth flow
+        oauth_settings: Optional[AsyncOAuthSettings] = None,
+        oauth_flow: Optional[AsyncOAuthFlow] = None,
+        # No need to set (the value is used only in response to ssl_check requests)
+        verification_token: Optional[str] = None,
+        # 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.
+
+            import os
+            from slack_bolt.async_app import AsyncApp
+
+            # Initializes your app with your bot token and signing secret
+            app = AsyncApp(
+                token=os.environ.get("SLACK_BOT_TOKEN"),
+                signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+            )
+
+            # Listens to incoming messages that contain "hello"
+            @app.message("hello")
+            async def message_hello(message, say):  # async function
+                # say() sends a message to the channel where the event was triggered
+                await say(f"Hey there <@{message['user']}>!")
+
+            # Start your app
+            if __name__ == "__main__":
+                app.start(port=int(os.environ.get("PORT", 3000)))
+
+        Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details.
+
+        If you would like to build an OAuth app for enabling the app to run with multiple workspaces,
+        refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app.
+
+        Args:
+            logger: The custom logger that can be used in this app.
+            name: The application name that will be used in logging. If absent, the source file name will be used.
+            process_before_response: True if this app runs on Function as a Service. (Default: False)
+            raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
+                and use @app.error listeners instead of
+                the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+            signing_secret: The Signing Secret value used for verifying requests from Slack.
+            token: The bot/user access token required only for single-workspace app.
+            client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app.
+            before_authorize: A global middleware that can be executed right before authorize function
+            authorize: The function to authorize an incoming request from Slack
+                by checking if there is a team/user in the installation data.
+            user_facing_authorize_error_message: The user-facing error message to display
+                when the app is installed but the installation is not managed by this app's installation store
+            installation_store: The module offering save/find operations of installation data
+            installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False)
+            request_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncRequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests.
+                Make sure if it's safe enough when you turn a built-in middleware off.
+                We strongly recommend using RequestVerification for better security.
+                If you have a proxy that verifies request signature in front of the Bolt app,
+                it's totally fine to disable RequestVerification to avoid duplication of work.
+                Don't turn it off just for easiness of development.
+            ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events
+                generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+            ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware.
+                `IgnoringSelfEvents` for this app's bot user message events within an assistant thread
+                This is useful for avoiding code error causing an infinite loop; Default: True
+            url_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncUrlVerification` is a built-in middleware that handles url_verification requests
+                that verify the endpoint for Events API in HTTP Mode requests.
+            ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True).
+                `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack.
+            attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncAttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution token
+                when your app receives `function_executed` or interactivity events scoped to a custom step.
+            oauth_settings: The settings related to Slack app installation flow (OAuth flow)
+            oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings.
+            verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests.
+            assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation,
+                which uses a parent message's metadata to store the latest context)
+        """
+        if signing_secret is None:
+            signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "")
+        token = token or os.environ.get("SLACK_BOT_TOKEN")
+
+        self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1]
+        self._signing_secret: str = signing_secret
+        self._verification_token: Optional[str] = verification_token or os.environ.get("SLACK_VERIFICATION_TOKEN", None)
+        # If a logger is explicitly passed when initializing, the logger works as the base logger.
+        # The base logger's logging settings will be propagated to all the loggers created by bolt-python.
+        self._base_logger = logger
+        # The framework logger is supposed to be used for the internal logging.
+        # Also, it's accessible via `app.logger` as the app's singleton logger.
+        self._framework_logger = logger or get_bolt_logger(AsyncApp)
+        self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
+
+        self._token: Optional[str] = token
+
+        if client is not None:
+            if not isinstance(client, AsyncWebClient):
+                raise BoltError(error_client_invalid_type_async())
+            self._async_client = client
+            self._token = client.token
+            if token is not None:
+                self._framework_logger.warning(warning_client_prioritized_and_token_skipped())
+        else:
+            self._async_client = create_async_web_client(
+                # NOTE: the token here can be None
+                token=token,
+                logger=self._framework_logger,
+            )
+
+        # --------------------------------------
+        # Authorize & OAuthFlow initialization
+        # --------------------------------------
+
+        self._async_before_authorize: Optional[AsyncMiddleware] = None
+        if before_authorize is not None:
+            if callable(before_authorize):
+                self._async_before_authorize = AsyncCustomMiddleware(
+                    app_name=self._name,
+                    func=before_authorize,
+                    base_logger=self._framework_logger,
+                )
+            elif isinstance(before_authorize, AsyncMiddleware):
+                self._async_before_authorize = before_authorize
+
+        self._async_authorize: Optional[AsyncAuthorize] = None
+        if authorize is not None:
+            if isinstance(authorize, AsyncAuthorize):
+                # As long as an advanced developer understands what they're doing,
+                # bolt-python should not prevent customizing authorize middleware
+                self._async_authorize = authorize
+            else:
+                if oauth_settings is not None or oauth_flow is not None:
+                    # If the given authorize is a simple function,
+                    # it does not work along with installation_store.
+                    raise BoltError(error_authorize_conflicts())
+                self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize)
+
+        self._async_installation_store: Optional[AsyncInstallationStore] = installation_store
+        if self._async_installation_store is not None and self._async_authorize is None:
+            settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
+            self._async_authorize = AsyncInstallationStoreAuthorize(
+                installation_store=self._async_installation_store,
+                client_id=settings.client_id if settings is not None else None,
+                client_secret=settings.client_secret if settings is not None else None,
+                logger=self._framework_logger,
+                bot_only=installation_store_bot_only or False,
+                client=self._async_client,  # for proxy use cases etc.
+                user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"),
+            )
+
+        self._async_oauth_flow: Optional[AsyncOAuthFlow] = None
+
+        if (
+            oauth_settings is None
+            and os.environ.get("SLACK_CLIENT_ID") is not None
+            and os.environ.get("SLACK_CLIENT_SECRET") is not None
+        ):
+            # initialize with the default settings
+            oauth_settings = AsyncOAuthSettings()
+
+            if oauth_flow is None and installation_store is None:
+                # show info-level log for avoiding confusions
+                self._framework_logger.info(info_default_oauth_settings_loaded())
+
+        if oauth_flow:
+            if not isinstance(oauth_flow, AsyncOAuthFlow):
+                raise BoltError(error_oauth_flow_invalid_type_async())
+
+            self._async_oauth_flow = oauth_flow
+            installation_store = select_consistent_installation_store(
+                client_id=self._async_oauth_flow.client_id,
+                app_store=self._async_installation_store,
+                oauth_flow_store=self._async_oauth_flow.settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._async_installation_store = installation_store
+            if installation_store is not None:
+                self._async_oauth_flow.settings.installation_store = installation_store
+
+            if self._async_oauth_flow._async_client is None:
+                self._async_oauth_flow._async_client = self._async_client
+            if self._async_authorize is None:
+                self._async_authorize = self._async_oauth_flow.settings.authorize
+        elif oauth_settings is not None:
+            if not isinstance(oauth_settings, AsyncOAuthSettings):
+                raise BoltError(error_oauth_settings_invalid_type_async())
+
+            installation_store = select_consistent_installation_store(
+                client_id=oauth_settings.client_id,
+                app_store=self._async_installation_store,
+                oauth_flow_store=oauth_settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._async_installation_store = installation_store
+            if installation_store is not None:
+                oauth_settings.installation_store = installation_store
+
+            self._async_oauth_flow = AsyncOAuthFlow(client=self._async_client, logger=self.logger, settings=oauth_settings)
+            if self._async_authorize is None:
+                self._async_authorize = self._async_oauth_flow.settings.authorize
+            self._async_authorize.token_rotation_expiration_minutes = oauth_settings.token_rotation_expiration_minutes  # type: ignore[attr-defined] # noqa: E501
+
+        if (self._async_installation_store is not None or self._async_authorize is not None) and self._token is not None:
+            self._token = None
+            self._framework_logger.warning(warning_token_skipped())
+
+        # after setting bot_only here, __init__ cannot replace authorize function
+        if installation_store_bot_only is not None and self._async_oauth_flow is not None:
+            app_bot_only = installation_store_bot_only or False
+            oauth_flow_bot_only = self._async_oauth_flow.settings.installation_store_bot_only
+            if app_bot_only != oauth_flow_bot_only:
+                self.logger.warning(warning_bot_only_conflicts())
+                self._async_oauth_flow.settings.installation_store_bot_only = app_bot_only
+                self._async_authorize.bot_only = app_bot_only  # type: ignore[union-attr]
+
+        self._async_tokens_revocation_listeners: Optional[AsyncTokenRevocationListeners] = None
+        if self._async_installation_store is not None:
+            self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners(self._async_installation_store)
+
+        # --------------------------------------
+        # Middleware Initialization
+        # --------------------------------------
+
+        self._async_middleware_list: List[AsyncMiddleware] = []
+        self._async_listeners: List[AsyncListener] = []
+
+        self._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(
+            logger=self._framework_logger,
+            process_before_response=process_before_response,
+            listener_error_handler=AsyncDefaultListenerErrorHandler(logger=self._framework_logger),
+            listener_start_handler=AsyncDefaultListenerStartHandler(logger=self._framework_logger),
+            listener_completion_handler=AsyncDefaultListenerCompletionHandler(logger=self._framework_logger),
+            lazy_listener_runner=AsyncioLazyListenerRunner(
+                logger=self._framework_logger,
+            ),
+        )
+        self._async_middleware_error_handler: AsyncMiddlewareErrorHandler = AsyncDefaultMiddlewareErrorHandler(
+            logger=self._framework_logger,
+        )
+
+        self._init_middleware_list_done = False
+        self._init_async_middleware_list(
+            request_verification_enabled=request_verification_enabled,
+            ignoring_self_events_enabled=ignoring_self_events_enabled,
+            ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+            ssl_check_enabled=ssl_check_enabled,
+            url_verification_enabled=url_verification_enabled,
+            attaching_function_token_enabled=attaching_function_token_enabled,
+            user_facing_authorize_error_message=user_facing_authorize_error_message,
+        )
+
+        self._server: Optional[AsyncSlackAppServer] = None
+
+    def _init_async_middleware_list(
+        self,
+        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,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        if self._init_middleware_list_done:
+            return
+        if ssl_check_enabled is True:
+            self._async_middleware_list.append(
+                AsyncSslCheck(
+                    verification_token=self._verification_token,
+                    base_logger=self._base_logger,
+                )
+            )
+        if request_verification_enabled is True:
+            self._async_middleware_list.append(AsyncRequestVerification(self._signing_secret, base_logger=self._base_logger))
+
+        if self._async_before_authorize is not None:
+            self._async_middleware_list.append(self._async_before_authorize)
+
+        # As authorize is required for making a Bolt app function, we don't offer the flag to disable this
+        if self._async_oauth_flow is None:
+            if self._token:
+                self._async_middleware_list.append(
+                    AsyncSingleTeamAuthorization(
+                        base_logger=self._base_logger,
+                        user_facing_authorize_error_message=user_facing_authorize_error_message,
+                    )
+                )
+            elif self._async_authorize is not None:
+                self._async_middleware_list.append(
+                    AsyncMultiTeamsAuthorization(
+                        authorize=self._async_authorize,
+                        base_logger=self._base_logger,
+                        user_facing_authorize_error_message=user_facing_authorize_error_message,
+                    )
+                )
+            else:
+                raise BoltError(error_token_required())
+        elif self._async_authorize is not None:
+            self._async_middleware_list.append(
+                AsyncMultiTeamsAuthorization(
+                    authorize=self._async_authorize,
+                    base_logger=self._base_logger,
+                    user_token_resolution=self._async_oauth_flow.settings.user_token_resolution,
+                    user_facing_authorize_error_message=user_facing_authorize_error_message,
+                )
+            )
+        else:
+            raise BoltError(error_oauth_flow_or_authorize_required())
+
+        if ignoring_self_events_enabled is True:
+            self._async_middleware_list.append(
+                AsyncIgnoringSelfEvents(
+                    base_logger=self._base_logger,
+                    ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+                )
+            )
+        if url_verification_enabled is True:
+            self._async_middleware_list.append(AsyncUrlVerification(base_logger=self._base_logger))
+        if attaching_function_token_enabled is True:
+            self._async_middleware_list.append(AsyncAttachingFunctionToken())
+        self._init_middleware_list_done = True
+
+    # -------------------------
+    # accessors
+
+    @property
+    def name(self) -> str:
+        """The name of this app (default: the filename)"""
+        return self._name
+
+    @property
+    def oauth_flow(self) -> Optional[AsyncOAuthFlow]:
+        """Configured `OAuthFlow` object if exists."""
+        return self._async_oauth_flow
+
+    @property
+    def client(self) -> AsyncWebClient:
+        """The singleton `slack_sdk.web.async_client.AsyncWebClient` instance in this app."""
+        return self._async_client
+
+    @property
+    def logger(self) -> logging.Logger:
+        """The logger this app uses."""
+        return self._framework_logger
+
+    @property
+    def installation_store(self) -> Optional[AsyncInstallationStore]:
+        """The `slack_sdk.oauth.AsyncInstallationStore` that can be used in the `authorize` middleware."""
+        return self._async_installation_store
+
+    @property
+    def listener_runner(self) -> AsyncioListenerRunner:
+        """The asyncio-based executor for asynchronously running listeners."""
+        return self._async_listener_runner
+
+    @property
+    def process_before_response(self) -> bool:
+        return self._process_before_response or False
+
+    # -------------------------
+    # standalone server
+
+    from .async_server import AsyncSlackAppServer
+
+    def server(
+        self,
+        port: int = 3000,
+        path: str = "/slack/events",
+        host: Optional[str] = None,
+    ) -> AsyncSlackAppServer:
+        """Configure a web server using AIOHTTP.
+        Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+        Args:
+            port: The port to listen on (Default: 3000)
+            path: The path to handle request from Slack (Default: `/slack/events`)
+            host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+        """
+        if self._server is None or self._server.port != port or self._server.path != path:
+            self._server = AsyncSlackAppServer(
+                port=port,
+                path=path,
+                app=self,
+                host=host,
+            )
+        return self._server
+
+    def web_app(self, path: str = "/slack/events", port: int = 3000) -> web.Application:
+        """Returns a `web.Application` instance for aiohttp-devtools users.
+
+            from slack_bolt.async_app import AsyncApp
+            app = AsyncApp()
+
+            @app.event("app_mention")
+            async def event_test(body, say, logger):
+                logger.info(body)
+                await say("What's up?")
+
+            def app_factory():
+                return app.web_app()
+
+            # adev runserver --port 3000 --app-factory app_factory async_app.py
+
+        Args:
+            path: The path to receive incoming requests from Slack
+            port: The port to listen on (Default: 3000)
+        """
+        return self.server(path=path, port=port).web_app
+
+    def start(self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None) -> None:
+        """Start a web server using AIOHTTP.
+        Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+        Args:
+            port: The port to listen on (Default: 3000)
+            path: The path to handle request from Slack (Default: `/slack/events`)
+            host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+        """
+        self.server(port=port, path=path, host=host).start()
+
+    # -------------------------
+    # main dispatcher
+
+    async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse:
+        """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+        Args:
+            req: An incoming request from Slack.
+
+        Returns:
+            The response generated by this Bolt app.
+        """
+        starting_time = time.time()
+        self._init_context(req)
+
+        resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+        middleware_state = {"next_called": False}
+
+        async def async_middleware_next():
+            middleware_state["next_called"] = True
+
+        try:
+            for middleware in self._async_middleware_list:
+                middleware_state["next_called"] = False
+                if self._framework_logger.level <= logging.DEBUG:
+                    self._framework_logger.debug(f"Applying {middleware.name}")
+                resp = await middleware.async_process(
+                    req=req, resp=resp, next=async_middleware_next  # type: ignore[arg-type]
+                )
+                if not middleware_state["next_called"]:
+                    if resp is None:
+                        # next() method was not called without providing the response to return to Slack
+                        # This should not be an intentional handling in usual use cases.
+                        resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                        if self._raise_error_for_unhandled_request is True:
+                            try:
+                                raise BoltUnhandledRequestError(
+                                    request=req,
+                                    current_response=resp,
+                                    last_global_middleware_name=middleware.name,
+                                )
+                            except BoltUnhandledRequestError as e:
+                                await self._async_listener_runner.listener_error_handler.handle(
+                                    error=e,
+                                    request=req,
+                                    response=resp,
+                                )
+                            return resp
+                        self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                        return resp
+                    return resp
+
+            for listener in self._async_listeners:
+                listener_name = get_name_for_callable(listener.ack_function)
+                self._framework_logger.debug(debug_checking_listener(listener_name))
+                if await listener.async_matches(req=req, resp=resp):  # type: ignore[arg-type]
+                    # run all the middleware attached to this listener first
+                    middleware_resp, next_was_not_called = await listener.run_async_middleware(
+                        req=req, resp=resp  # type: ignore[arg-type]
+                    )
+                    if next_was_not_called:
+                        if middleware_resp is not None:
+                            if self._framework_logger.level <= logging.DEBUG:
+                                debug_message = debug_return_listener_middleware_response(
+                                    listener_name,
+                                    middleware_resp.status,
+                                    middleware_resp.body,
+                                    starting_time,
+                                )
+                                self._framework_logger.debug(debug_message)
+                            return middleware_resp
+                        # The last listener middleware didn't call next() method.
+                        # This means the listener is not for this incoming request.
+                        continue
+
+                    if middleware_resp is not None:
+                        resp = middleware_resp
+
+                    self._framework_logger.debug(debug_running_listener(listener_name))
+                    listener_response: Optional[BoltResponse] = await self._async_listener_runner.run(
+                        request=req,
+                        response=resp,  # type: ignore[arg-type]
+                        listener_name=listener_name,
+                        listener=listener,
+                    )
+                    if listener_response is not None:
+                        return listener_response
+
+            if resp is None:
+                resp = BoltResponse(status=404, body={"error": "unhandled request"})
+            if self._raise_error_for_unhandled_request is True:
+                try:
+                    raise BoltUnhandledRequestError(
+                        request=req,
+                        current_response=resp,
+                    )
+                except BoltUnhandledRequestError as e:
+                    await self._async_listener_runner.listener_error_handler.handle(
+                        error=e,
+                        request=req,
+                        response=resp,
+                    )
+                return resp
+            return self._handle_unmatched_requests(req, resp)
+
+        except Exception as error:
+            resp = BoltResponse(status=500, body="")
+            await self._async_middleware_error_handler.handle(
+                error=error,
+                request=req,
+                response=resp,
+            )
+            return resp
+
+    def _handle_unmatched_requests(self, req: AsyncBoltRequest, resp: BoltResponse) -> BoltResponse:
+        self._framework_logger.warning(warning_unhandled_request(req))
+        return resp
+
+    # -------------------------
+    # middleware
+
+    def use(self, *args) -> Optional[Callable]:
+        """Refer to `AsyncApp#middleware()` method's docstring for details."""
+        return self.middleware(*args)
+
+    def middleware(self, *args) -> Optional[Callable]:
+        """Registers a new middleware to this app.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.middleware
+            async def middleware_func(logger, body, next):
+                logger.info(f"request body: {body}")
+                await next()
+
+            # Pass a function to this method
+            app.middleware(middleware_func)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            *args: A function that works as a global middleware.
+        """
+        if len(args) > 0:
+            middleware_or_callable = args[0]
+            if isinstance(middleware_or_callable, AsyncMiddleware):
+                middleware: AsyncMiddleware = middleware_or_callable
+                self._async_middleware_list.append(middleware)
+                if isinstance(middleware, AsyncAssistant) and middleware.thread_context_store is not None:
+                    self._assistant_thread_context_store = middleware.thread_context_store
+            elif callable(middleware_or_callable):
+                self._async_middleware_list.append(
+                    AsyncCustomMiddleware(
+                        app_name=self.name,
+                        func=middleware_or_callable,
+                        base_logger=self._base_logger,
+                    )
+                )
+                return middleware_or_callable
+            else:
+                raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+        return None
+
+    def assistant(self, assistant: AsyncAssistant) -> Optional[Callable]:
+        return self.middleware(assistant)
+
+    # -------------------------
+    # Workflows: Steps from apps
+
+    def step(
+        self,
+        callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder],
+        edit: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+        save: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+        execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new step from app listener.
+
+        Unlike others, this method doesn't behave as a decorator.
+        If you want to register a step from app by a decorator, use `AsyncWorkflowStepBuilder`'s methods.
+
+            # Create a new WorkflowStep instance
+            from slack_bolt.workflows.async_step import AsyncWorkflowStep
+            ws = AsyncWorkflowStep(
+                callback_id="add_task",
+                edit=edit,
+                save=save,
+                execute=execute,
+            )
+            # Pass Step to set up listeners
+            app.step(ws)
+
+        Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+        For further information about AsyncWorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            callback_id: The Callback ID for this step from app
+            edit: The function for displaying a modal in the Workflow Builder
+            save: The function for handling configuration in the Workflow Builder
+            execute: The function for handling the step execution
+        """
+        warnings.warn(
+            (
+                "Steps from apps for legacy workflows are now deprecated. "
+                "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+            ),
+            category=DeprecationWarning,
+        )
+        step = callback_id
+        if isinstance(callback_id, (str, Pattern)):
+            step = AsyncWorkflowStep(
+                callback_id=callback_id,
+                edit=edit,  # type: ignore[arg-type]
+                save=save,  # type: ignore[arg-type]
+                execute=execute,  # type: ignore[arg-type]
+                base_logger=self._base_logger,
+            )
+        elif isinstance(step, AsyncWorkflowStepBuilder):
+            step = step.build(base_logger=self._base_logger)
+        elif not isinstance(step, AsyncWorkflowStep):
+            raise BoltError(f"Invalid step object ({type(step)})")
+
+        self.use(AsyncWorkflowStepMiddleware(step))
+
+    # -------------------------
+    # global error handler
+
+    def error(
+        self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
+    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+        """Updates the global error handler. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.error
+            async def custom_error_handler(error, body, logger):
+                logger.exception(f"Error: {error}")
+                logger.info(f"Request body: {body}")
+
+            # Pass a function to this method
+            app.error(custom_error_handler)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            func: The function that is supposed to be executed
+                when getting an unhandled error in Bolt app.
+        """
+        if not is_callable_coroutine(func):
+            name = get_name_for_callable(func)
+            raise BoltError(error_listener_function_must_be_coro_func(name))
+        self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        return func
+
+    # -------------------------
+    # events
+
+    def event(
+        self,
+        event: Union[
+            str,
+            Pattern,
+            Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+        ],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new event listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.event("team_join")
+            async def ask_for_introduction(event, say):
+                welcome_channel_id = "C12345"
+                user_id = event["user"]
+                text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+                await say(text=text, channel=welcome_channel_id)
+
+            # Pass a function to this method
+            app.event("team_join")(ask_for_introduction)
+
+        Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            event: The conditions that match a request payload.
+                If you pass a dict for this, you can have type, subtype in the constraint.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        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__
+
+    def message(
+        self,
+        keyword: Union[str, Pattern] = "",
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new message event listener. This method can be used as either a decorator or a method.
+        Check the `App#event` method's docstring for details.
+
+            # Use this method as a decorator
+            @app.message(":wave:")
+            async def say_hello(message, say):
+                user = message['user']
+                await say(f"Hi there, <@{user}>!")
+
+            # Pass a function to this method
+            app.message(":wave:")(say_hello)
+
+        Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            keyword: The keyword to match
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            constraints = {
+                "type": "message",
+                "subtype": (
+                    # In most cases, new message events come with no subtype.
+                    None,
+                    # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                    # By contrast, messages posted using classic app's bot token still have the subtype.
+                    "bot_message",
+                    # If an end-user posts a message with "Also send to #channel" checked,
+                    # the message event comes with this subtype.
+                    "thread_broadcast",
+                    # If an end-user posts a message with attached files,
+                    # the message event comes with this subtype.
+                    "file_share",
+                ),
+            }
+            primary_matcher = builtin_matchers.message_event(
+                constraints=constraints,
+                keyword=keyword,
+                asyncio=True,
+                base_logger=self._base_logger,
+            )
+            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)
+
+        return __call__
+
+    def function(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+        auto_acknowledge: bool = True,
+        ack_timeout: int = 3,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]:
+        """Registers a new Function listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.function("reverse")
+            async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail):
+                try:
+                    await ack()
+                    string_to_reverse = inputs["stringToReverse"]
+                    await complete({"reverseString": string_to_reverse[::-1]})
+                except Exception as e:
+                    await fail(f"Cannot reverse string (error: {e})")
+                    raise e
+
+            # Pass a function to this method
+            app.function("reverse")(reverse_string)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            callback_id: The callback id to identify the function
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        if auto_acknowledge is True:
+            if ack_timeout != 3:
+                self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.function_executed(
+                callback_id=callback_id, base_logger=self._base_logger, asyncio=True
+            )
+            return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+        return __call__
+
+    # -------------------------
+    # slash commands
+
+    def command(
+        self,
+        command: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new slash command listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.command("/echo")
+            async def repeat_text(ack, say, command):
+                # Acknowledge command request
+                await ack()
+                await say(f"{command['text']}")
+
+            # Pass a function to this method
+            app.command("/echo")(repeat_text)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            command: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.command(command, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # shortcut
+
+    def shortcut(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new shortcut listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.shortcut("open_modal")
+            async def open_modal(ack, body, client):
+                # Acknowledge the command request
+                await ack()
+                # Call views_open with the built-in client
+                await client.views_open(
+                    # Pass a valid trigger_id within 3 seconds of receiving it
+                    trigger_id=body["trigger_id"],
+                    # View payload
+                    view={ ... }
+                )
+
+            # Pass a function to this method
+            app.shortcut("open_modal")(open_modal)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.shortcut(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def global_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new global shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.global_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def message_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new message shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.message_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # action
+
+    def action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new action listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.action("approve_button")
+            async def update_message(ack):
+                await ack()
+
+            # Pass a function to this method
+            app.action("approve_button")(update_message)
+
+        * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+        * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+        * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.action(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `block_actions` action listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_action(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def attachment_action(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `interactive_message` action listener.
+        Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.attachment_action(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_submission(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `dialog_submission` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_submission(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_cancellation(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `dialog_submission` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_cancellation(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # view
+
+    def view(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `view_submission`/`view_closed` event listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.view("view_1")
+            async def handle_submission(ack, body, client, view):
+                # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+                hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+                user = body["user"]["id"]
+                # Validate the inputs
+                errors = {}
+                if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                    errors["block_c"] = "The value must be longer than 5 characters"
+                if len(errors) > 0:
+                    await ack(response_action="errors", errors=errors)
+                    return
+                # Acknowledge the view_submission event and close the modal
+                await ack()
+                # Do whatever you want with the input data - here we're saving it to a DB
+
+            # Pass a function to this method
+            app.view("view_1")(handle_submission)
+
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_submission(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `view_submission` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+        details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_submission(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_closed(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `view_closed` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_closed(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # options
+
+    def options(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new options listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.options("menu_selection")
+            async def show_menu_options(ack):
+                options = [
+                    {
+                        "text": {"type": "plain_text", "text": "Option 1"},
+                        "value": "1-1",
+                    },
+                    {
+                        "text": {"type": "plain_text", "text": "Option 2"},
+                        "value": "1-2",
+                    },
+                ]
+                await ack(options=options)
+
+            # Pass a function to this method
+            app.options("menu_selection")(show_menu_options)
+
+        Refer to the following documents for details:
+
+        * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+        * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.options(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_suggestion(
+        self,
+        action_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `block_suggestion` listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_suggestion(action_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_suggestion(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `dialog_suggestion` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_suggestion(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # built-in listener functions
+
+    def default_tokens_revoked_event_listener(
+        self,
+    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+        if self._async_tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._async_tokens_revocation_listeners.handle_tokens_revoked_events
+
+    def default_app_uninstalled_event_listener(
+        self,
+    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+        if self._async_tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._async_tokens_revocation_listeners.handle_app_uninstalled_events
+
+    def enable_token_revocation_listeners(self) -> None:
+        self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+        self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+    # -------------------------
+
+    def _init_context(self, req: AsyncBoltRequest):
+        req.context["logger"] = get_bolt_app_logger(app_name=self.name, base_logger=self._base_logger)
+        req.context["token"] = self._token
+        # Prior to version 1.15, when the token is static, self._client was passed to `req.context`.
+        # The intention was to avoid creating a new instance per request
+        # in the interest of runtime performance/memory footprint optimization.
+        # However, developers may want to replace the token held by req.context.client in some situations.
+        # In this case, this behavior can result in thread-unsafe data modification on `self._client`.
+        # (`self._client` a.k.a. `app.client` is a singleton object per an App instance)
+        # Thus, we've changed the behavior to create a new instance per request regardless of token argument
+        # in the App initialization starting v1.15.
+        # The overhead brought by this change is slight so that we believe that it is ignorable in any cases.
+        client_per_request: AsyncWebClient = AsyncWebClient(
+            token=self._token,  # this can be None, and it can be set later on
+            base_url=self._async_client.base_url,
+            timeout=self._async_client.timeout,
+            ssl=self._async_client.ssl,
+            proxy=self._async_client.proxy,
+            session=self._async_client.session,
+            trust_env_in_session=self._async_client.trust_env_in_session,
+            headers=self._async_client.headers,
+            team_id=req.context.team_id,
+            logger=self._async_client.logger,
+            retry_handlers=(
+                self._async_client.retry_handlers.copy() if self._async_client.retry_handlers is not None else None
+            ),
+        )
+        req.context["client"] = client_per_request
+
+        # Most apps do not need this "listener_runner" instance.
+        # It is intended for apps that start lazy listeners from their custom global middleware.
+        req.context["listener_runner"] = self.listener_runner
+
+    @staticmethod
+    def _to_listener_functions(
+        kwargs: dict,
+    ) -> Optional[Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        if kwargs:
+            functions = [kwargs["ack"]]
+            for sub in kwargs["lazy"]:
+                functions.append(sub)
+            return functions
+        return None
+
+    def _register_listener(
+        self,
+        functions: Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]],
+        primary_matcher: AsyncListenerMatcher,
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]],
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+    ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]:
+        value_to_return = None
+        if not isinstance(functions, list):
+            functions = list(functions)
+        if len(functions) == 1:
+            # In the case where the function is registered using decorator,
+            # the registration should return the original function.
+            value_to_return = functions[0]
+
+        for func in functions:
+            if not is_callable_coroutine(func):
+                name = get_name_for_callable(func)
+                raise BoltError(error_listener_function_must_be_coro_func(name))
+
+        listener_matchers: List[AsyncListenerMatcher] = [
+            AsyncCustomListenerMatcher(app_name=self.name, func=f, base_logger=self._base_logger) for f in (matchers or [])
+        ]
+        listener_matchers.insert(0, primary_matcher)
+        listener_middleware = []
+        for m in middleware or []:
+            if isinstance(m, AsyncMiddleware):
+                listener_middleware.append(m)
+            elif callable(m) and is_callable_coroutine(m):
+                listener_middleware.append(AsyncCustomMiddleware(app_name=self.name, func=m, base_logger=self._base_logger))
+            else:
+                raise ValueError(error_unexpected_listener_middleware(type(m)))
+
+        self._async_listeners.append(
+            AsyncCustomListener(
+                app_name=self.name,
+                ack_function=functions.pop(0),
+                lazy_functions=functions,  # type: ignore[arg-type]
+                matchers=listener_matchers,
+                middleware=listener_middleware,
+                auto_acknowledgement=auto_acknowledgement,
+                ack_timeout=ack_timeout,
+                base_logger=self._base_logger,
+            )
+        )
+
+        return value_to_return
+
+

Bolt App that provides functionalities to register middleware/listeners.

+
import os
+from slack_bolt.async_app import AsyncApp
+
+# Initializes your app with your bot token and signing secret
+app = AsyncApp(
+    token=os.environ.get("SLACK_BOT_TOKEN"),
+    signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+)
+
+# Listens to incoming messages that contain "hello"
+@app.message("hello")
+async def message_hello(message, say):  # async function
+    # say() sends a message to the channel where the event was triggered
+    await say(f"Hey there <@{message['user']}>!")
+
+# Start your app
+if __name__ == "__main__":
+    app.start(port=int(os.environ.get("PORT", 3000)))
+
+

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

+

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

+

Args

+
+
logger
+
The custom logger that can be used in this app.
+
name
+
The application name that will be used in logging. If absent, the source file name will be used.
+
process_before_response
+
True if this app runs on Function as a Service. (Default: False)
+
raise_error_for_unhandled_request
+
True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+
signing_secret
+
The Signing Secret value used for verifying requests from Slack.
+
token
+
The bot/user access token required only for single-workspace app.
+
client
+
The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app.
+
before_authorize
+
A global middleware that can be executed right before authorize function
+
authorize
+
The function to authorize an incoming request from Slack +by checking if there is a team/user in the installation data.
+
user_facing_authorize_error_message
+
The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
+
installation_store
+
The module offering save/find operations of installation data
+
installation_store_bot_only
+
Use AsyncInstallationStore#async_find_bot() if True (Default: False)
+
request_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncRequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
+
ignoring_self_events_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncIgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+
ignoring_self_assistant_message_events_enabled
+
False if you would like to disable the built-in middleware. +IgnoringSelfEvents for this app's bot user message events within an assistant thread +This is useful for avoiding code error causing an infinite loop; Default: True
+
url_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncUrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
+
ssl_check_enabled
+
bool = False if you would like to disable the built-in middleware (Default: True). +AsyncSslCheck is a built-in middleware that handles ssl_check requests from Slack.
+
attaching_function_token_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncAttachingFunctionToken is a built-in middleware that injects the just-in-time workflow-execution token +when your app receives function_executed or interactivity events scoped to a custom step.
+
oauth_settings
+
The settings related to Slack app installation flow (OAuth flow)
+
oauth_flow
+
Instantiated slack_bolt.oauth.AsyncOAuthFlow. This is always prioritized over oauth_settings.
+
verification_token
+
Deprecated verification mechanism. This can be used only for ssl_check requests.
+
assistant_thread_context_store
+
Custom AssistantThreadContext store (Default: the built-in implementation, +which uses a parent message's metadata to store the latest context)
+
+

Class variables

+
+
var AsyncSlackAppServer
+
+

The type of the None singleton.

+
+
+

Instance variables

+
+
prop client : slack_sdk.web.async_client.AsyncWebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> AsyncWebClient:
+    """The singleton `slack_sdk.web.async_client.AsyncWebClient` instance in this app."""
+    return self._async_client
+
+

The singleton slack_sdk.web.async_client.AsyncWebClient instance in this app.

+
+
prop installation_store : slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None
+
+
+ +Expand source code + +
@property
+def installation_store(self) -> Optional[AsyncInstallationStore]:
+    """The `slack_sdk.oauth.AsyncInstallationStore` that can be used in the `authorize` middleware."""
+    return self._async_installation_store
+
+

The slack_sdk.oauth.AsyncInstallationStore that can be used in the authorize middleware.

+
+
prop listener_runnerAsyncioListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> AsyncioListenerRunner:
+    """The asyncio-based executor for asynchronously running listeners."""
+    return self._async_listener_runner
+
+

The asyncio-based executor for asynchronously running listeners.

+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> logging.Logger:
+    """The logger this app uses."""
+    return self._framework_logger
+
+

The logger this app uses.

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this app (default: the filename)"""
+    return self._name
+
+

The name of this app (default: the filename)

+
+
prop oauth_flowAsyncOAuthFlow | None
+
+
+ +Expand source code + +
@property
+def oauth_flow(self) -> Optional[AsyncOAuthFlow]:
+    """Configured `OAuthFlow` object if exists."""
+    return self._async_oauth_flow
+
+

Configured OAuthFlow object if exists.

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

Methods

+
+
+def action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new action listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.action("approve_button")
+        async def update_message(ack):
+            await ack()
+
+        # Pass a function to this method
+        app.action("approve_button")(update_message)
+
+    * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+    * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+    * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.action(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.action("approve_button")
+async def update_message(ack):
+    await ack()
+
+# Pass a function to this method
+app.action("approve_button")(update_message)
+
+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def assistant(self,
assistant: AsyncAssistant) ‑> Callable | None
+
+
+
+ +Expand source code + +
def assistant(self, assistant: AsyncAssistant) -> Optional[Callable]:
+    return self.middleware(assistant)
+
+
+
+
+async def async_dispatch(self,
req: AsyncBoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse:
+    """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+    Args:
+        req: An incoming request from Slack.
+
+    Returns:
+        The response generated by this Bolt app.
+    """
+    starting_time = time.time()
+    self._init_context(req)
+
+    resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+    middleware_state = {"next_called": False}
+
+    async def async_middleware_next():
+        middleware_state["next_called"] = True
+
+    try:
+        for middleware in self._async_middleware_list:
+            middleware_state["next_called"] = False
+            if self._framework_logger.level <= logging.DEBUG:
+                self._framework_logger.debug(f"Applying {middleware.name}")
+            resp = await middleware.async_process(
+                req=req, resp=resp, next=async_middleware_next  # type: ignore[arg-type]
+            )
+            if not middleware_state["next_called"]:
+                if resp is None:
+                    # next() method was not called without providing the response to return to Slack
+                    # This should not be an intentional handling in usual use cases.
+                    resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                    if self._raise_error_for_unhandled_request is True:
+                        try:
+                            raise BoltUnhandledRequestError(
+                                request=req,
+                                current_response=resp,
+                                last_global_middleware_name=middleware.name,
+                            )
+                        except BoltUnhandledRequestError as e:
+                            await self._async_listener_runner.listener_error_handler.handle(
+                                error=e,
+                                request=req,
+                                response=resp,
+                            )
+                        return resp
+                    self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                    return resp
+                return resp
+
+        for listener in self._async_listeners:
+            listener_name = get_name_for_callable(listener.ack_function)
+            self._framework_logger.debug(debug_checking_listener(listener_name))
+            if await listener.async_matches(req=req, resp=resp):  # type: ignore[arg-type]
+                # run all the middleware attached to this listener first
+                middleware_resp, next_was_not_called = await listener.run_async_middleware(
+                    req=req, resp=resp  # type: ignore[arg-type]
+                )
+                if next_was_not_called:
+                    if middleware_resp is not None:
+                        if self._framework_logger.level <= logging.DEBUG:
+                            debug_message = debug_return_listener_middleware_response(
+                                listener_name,
+                                middleware_resp.status,
+                                middleware_resp.body,
+                                starting_time,
+                            )
+                            self._framework_logger.debug(debug_message)
+                        return middleware_resp
+                    # The last listener middleware didn't call next() method.
+                    # This means the listener is not for this incoming request.
+                    continue
+
+                if middleware_resp is not None:
+                    resp = middleware_resp
+
+                self._framework_logger.debug(debug_running_listener(listener_name))
+                listener_response: Optional[BoltResponse] = await self._async_listener_runner.run(
+                    request=req,
+                    response=resp,  # type: ignore[arg-type]
+                    listener_name=listener_name,
+                    listener=listener,
+                )
+                if listener_response is not None:
+                    return listener_response
+
+        if resp is None:
+            resp = BoltResponse(status=404, body={"error": "unhandled request"})
+        if self._raise_error_for_unhandled_request is True:
+            try:
+                raise BoltUnhandledRequestError(
+                    request=req,
+                    current_response=resp,
+                )
+            except BoltUnhandledRequestError as e:
+                await self._async_listener_runner.listener_error_handler.handle(
+                    error=e,
+                    request=req,
+                    response=resp,
+                )
+            return resp
+        return self._handle_unmatched_requests(req, resp)
+
+    except Exception as error:
+        resp = BoltResponse(status=500, body="")
+        await self._async_middleware_error_handler.handle(
+            error=error,
+            request=req,
+            response=resp,
+        )
+        return resp
+
+

Applies all middleware and dispatches an incoming request from Slack to the right code path.

+

Args

+
+
req
+
An incoming request from Slack.
+
+

Returns

+

The response generated by this Bolt app.

+
+
+def attachment_action(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def attachment_action(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `interactive_message` action listener.
+    Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.attachment_action(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new interactive_message action listener. +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

+
+
+def block_action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def block_action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `block_actions` action listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_action(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_actions action listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

+
+
+def block_suggestion(self,
action_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def block_suggestion(
+    self,
+    action_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `block_suggestion` listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_suggestion(action_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_suggestion listener.

+
+
+def command(self,
command: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def command(
+    self,
+    command: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new slash command listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.command("/echo")
+        async def repeat_text(ack, say, command):
+            # Acknowledge command request
+            await ack()
+            await say(f"{command['text']}")
+
+        # Pass a function to this method
+        app.command("/echo")(repeat_text)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        command: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.command(command, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new slash command listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.command("/echo")
+async def repeat_text(ack, say, command):
+    # Acknowledge command request
+    await ack()
+    await say(f"{command['text']}")
+
+# Pass a function to this method
+app.command("/echo")(repeat_text)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
command
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def default_app_uninstalled_event_listener(self) ‑> Callable[..., Awaitable[BoltResponse | None]] +
+
+
+ +Expand source code + +
def default_app_uninstalled_event_listener(
+    self,
+) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+    if self._async_tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._async_tokens_revocation_listeners.handle_app_uninstalled_events
+
+
+
+
+def default_tokens_revoked_event_listener(self) ‑> Callable[..., Awaitable[BoltResponse | None]] +
+
+
+ +Expand source code + +
def default_tokens_revoked_event_listener(
+    self,
+) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+    if self._async_tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._async_tokens_revocation_listeners.handle_tokens_revoked_events
+
+
+
+
+def dialog_cancellation(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def dialog_cancellation(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `dialog_submission` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_cancellation(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_submission listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_submission(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def dialog_submission(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `dialog_submission` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_submission(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_submission listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_suggestion(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def dialog_suggestion(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `dialog_suggestion` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_suggestion(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_suggestion listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def enable_token_revocation_listeners(self) ‑> None +
+
+
+ +Expand source code + +
def enable_token_revocation_listeners(self) -> None:
+    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+
+
+
+def error(self,
func: Callable[..., Awaitable[BoltResponse | None]]) ‑> Callable[..., Awaitable[BoltResponse | None]]
+
+
+
+ +Expand source code + +
def error(
+    self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
+) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+    """Updates the global error handler. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.error
+        async def custom_error_handler(error, body, logger):
+            logger.exception(f"Error: {error}")
+            logger.info(f"Request body: {body}")
+
+        # Pass a function to this method
+        app.error(custom_error_handler)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        func: The function that is supposed to be executed
+            when getting an unhandled error in Bolt app.
+    """
+    if not is_callable_coroutine(func):
+        name = get_name_for_callable(func)
+        raise BoltError(error_listener_function_must_be_coro_func(name))
+    self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    return func
+
+

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

+
# Use this method as a decorator
+@app.error
+async def custom_error_handler(error, body, logger):
+    logger.exception(f"Error: {error}")
+    logger.info(f"Request body: {body}")
+
+# Pass a function to this method
+app.error(custom_error_handler)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
func
+
The function that is supposed to be executed +when getting an unhandled error in Bolt app.
+
+
+
+def event(self,
event: str | Pattern | Dict[str, str | Sequence[str | Pattern | None] | None],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def event(
+    self,
+    event: Union[
+        str,
+        Pattern,
+        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+    ],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new event listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.event("team_join")
+        async def ask_for_introduction(event, say):
+            welcome_channel_id = "C12345"
+            user_id = event["user"]
+            text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+            await say(text=text, channel=welcome_channel_id)
+
+        # Pass a function to this method
+        app.event("team_join")(ask_for_introduction)
+
+    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        event: The conditions that match a request payload.
+            If you pass a dict for this, you can have type, subtype in the constraint.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    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__
+
+

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

+
# Use this method as a decorator
+@app.event("team_join")
+async def ask_for_introduction(event, say):
+    welcome_channel_id = "C12345"
+    user_id = event["user"]
+    text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+    await say(text=text, channel=welcome_channel_id)
+
+# Pass a function to this method
+app.event("team_join")(ask_for_introduction)
+
+

Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
event
+
The conditions that match a request payload. +If you pass a dict for this, you can have type, subtype in the constraint.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def function(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None,
auto_acknowledge: bool = True,
ack_timeout: int = 3) ‑> Callable[..., Callable[..., Awaitable[BoltResponse]] | None]
+
+
+
+ +Expand source code + +
def function(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    auto_acknowledge: bool = True,
+    ack_timeout: int = 3,
+) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]:
+    """Registers a new Function listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.function("reverse")
+        async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail):
+            try:
+                await ack()
+                string_to_reverse = inputs["stringToReverse"]
+                await complete({"reverseString": string_to_reverse[::-1]})
+            except Exception as e:
+                await fail(f"Cannot reverse string (error: {e})")
+                raise e
+
+        # Pass a function to this method
+        app.function("reverse")(reverse_string)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        callback_id: The callback id to identify the function
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    if auto_acknowledge is True:
+        if ack_timeout != 3:
+            self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.function_executed(
+            callback_id=callback_id, base_logger=self._base_logger, asyncio=True
+        )
+        return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.function("reverse")
+async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail):
+    try:
+        await ack()
+        string_to_reverse = inputs["stringToReverse"]
+        await complete({"reverseString": string_to_reverse[::-1]})
+    except Exception as e:
+        await fail(f"Cannot reverse string (error: {e})")
+        raise e
+
+# Pass a function to this method
+app.function("reverse")(reverse_string)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
callback_id
+
The callback id to identify the function
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def global_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def global_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new global shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.global_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new global shortcut listener.

+
+
+def message(self,
keyword: str | Pattern = '',
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def message(
+    self,
+    keyword: Union[str, Pattern] = "",
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new message event listener. This method can be used as either a decorator or a method.
+    Check the `App#event` method's docstring for details.
+
+        # Use this method as a decorator
+        @app.message(":wave:")
+        async def say_hello(message, say):
+            user = message['user']
+            await say(f"Hi there, <@{user}>!")
+
+        # Pass a function to this method
+        app.message(":wave:")(say_hello)
+
+    Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        keyword: The keyword to match
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        constraints = {
+            "type": "message",
+            "subtype": (
+                # In most cases, new message events come with no subtype.
+                None,
+                # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                # By contrast, messages posted using classic app's bot token still have the subtype.
+                "bot_message",
+                # If an end-user posts a message with "Also send to #channel" checked,
+                # the message event comes with this subtype.
+                "thread_broadcast",
+                # If an end-user posts a message with attached files,
+                # the message event comes with this subtype.
+                "file_share",
+            ),
+        }
+        primary_matcher = builtin_matchers.message_event(
+            constraints=constraints,
+            keyword=keyword,
+            asyncio=True,
+            base_logger=self._base_logger,
+        )
+        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)
+
+    return __call__
+
+

Registers a new message event listener. This method can be used as either a decorator or a method. +Check the App#event method's docstring for details.

+
# Use this method as a decorator
+@app.message(":wave:")
+async def say_hello(message, say):
+    user = message['user']
+    await say(f"Hi there, <@{user}>!")
+
+# Pass a function to this method
+app.message(":wave:")(say_hello)
+
+

Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
keyword
+
The keyword to match
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def message_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def message_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new message shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.message_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new message shortcut listener.

+
+
+def middleware(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def middleware(self, *args) -> Optional[Callable]:
+    """Registers a new middleware to this app.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.middleware
+        async def middleware_func(logger, body, next):
+            logger.info(f"request body: {body}")
+            await next()
+
+        # Pass a function to this method
+        app.middleware(middleware_func)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        *args: A function that works as a global middleware.
+    """
+    if len(args) > 0:
+        middleware_or_callable = args[0]
+        if isinstance(middleware_or_callable, AsyncMiddleware):
+            middleware: AsyncMiddleware = middleware_or_callable
+            self._async_middleware_list.append(middleware)
+            if isinstance(middleware, AsyncAssistant) and middleware.thread_context_store is not None:
+                self._assistant_thread_context_store = middleware.thread_context_store
+        elif callable(middleware_or_callable):
+            self._async_middleware_list.append(
+                AsyncCustomMiddleware(
+                    app_name=self.name,
+                    func=middleware_or_callable,
+                    base_logger=self._base_logger,
+                )
+            )
+            return middleware_or_callable
+        else:
+            raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+    return None
+
+

Registers a new middleware to this app. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.middleware
+async def middleware_func(logger, body, next):
+    logger.info(f"request body: {body}")
+    await next()
+
+# Pass a function to this method
+app.middleware(middleware_func)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
*args
+
A function that works as a global middleware.
+
+
+
+def options(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def options(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new options listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.options("menu_selection")
+        async def show_menu_options(ack):
+            options = [
+                {
+                    "text": {"type": "plain_text", "text": "Option 1"},
+                    "value": "1-1",
+                },
+                {
+                    "text": {"type": "plain_text", "text": "Option 2"},
+                    "value": "1-2",
+                },
+            ]
+            await ack(options=options)
+
+        # Pass a function to this method
+        app.options("menu_selection")(show_menu_options)
+
+    Refer to the following documents for details:
+
+    * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+    * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.options(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.options("menu_selection")
+async def show_menu_options(ack):
+    options = [
+        {
+            "text": {"type": "plain_text", "text": "Option 1"},
+            "value": "1-1",
+        },
+        {
+            "text": {"type": "plain_text", "text": "Option 2"},
+            "value": "1-2",
+        },
+    ]
+    await ack(options=options)
+
+# Pass a function to this method
+app.options("menu_selection")(show_menu_options)
+
+

Refer to the following documents for details:

+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def server(self, port: int = 3000, path: str = '/slack/events', host: str | None = None) ‑> AsyncSlackAppServer +
+
+
+ +Expand source code + +
def server(
+    self,
+    port: int = 3000,
+    path: str = "/slack/events",
+    host: Optional[str] = None,
+) -> AsyncSlackAppServer:
+    """Configure a web server using AIOHTTP.
+    Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+    Args:
+        port: The port to listen on (Default: 3000)
+        path: The path to handle request from Slack (Default: `/slack/events`)
+        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+    """
+    if self._server is None or self._server.port != port or self._server.path != path:
+        self._server = AsyncSlackAppServer(
+            port=port,
+            path=path,
+            app=self,
+            host=host,
+        )
+    return self._server
+
+

Configure a web server using AIOHTTP. +Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.

+

Args

+
+
port
+
The port to listen on (Default: 3000)
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
host
+
The hostname to serve the web endpoints. (Default: 0.0.0.0)
+
+
+
+def shortcut(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def shortcut(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new shortcut listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.shortcut("open_modal")
+        async def open_modal(ack, body, client):
+            # Acknowledge the command request
+            await ack()
+            # Call views_open with the built-in client
+            await client.views_open(
+                # Pass a valid trigger_id within 3 seconds of receiving it
+                trigger_id=body["trigger_id"],
+                # View payload
+                view={ ... }
+            )
+
+        # Pass a function to this method
+        app.shortcut("open_modal")(open_modal)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.shortcut(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.shortcut("open_modal")
+async def open_modal(ack, body, client):
+    # Acknowledge the command request
+    await ack()
+    # Call views_open with the built-in client
+    await client.views_open(
+        # Pass a valid trigger_id within 3 seconds of receiving it
+        trigger_id=body["trigger_id"],
+        # View payload
+        view={ ... }
+    )
+
+# Pass a function to this method
+app.shortcut("open_modal")(open_modal)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def start(self, port: int = 3000, path: str = '/slack/events', host: str | None = None) ‑> None +
+
+
+ +Expand source code + +
def start(self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None) -> None:
+    """Start a web server using AIOHTTP.
+    Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+    Args:
+        port: The port to listen on (Default: 3000)
+        path: The path to handle request from Slack (Default: `/slack/events`)
+        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+    """
+    self.server(port=port, path=path, host=host).start()
+
+

Start a web server using AIOHTTP. +Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.

+

Args

+
+
port
+
The port to listen on (Default: 3000)
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
host
+
The hostname to serve the web endpoints. (Default: 0.0.0.0)
+
+
+
+def step(self,
callback_id: str | Pattern | AsyncWorkflowStep | AsyncWorkflowStepBuilder,
edit: Callable[..., BoltResponse | None] | AsyncListener | Sequence[Callable] | None = None,
save: Callable[..., BoltResponse | None] | AsyncListener | Sequence[Callable] | None = None,
execute: Callable[..., BoltResponse | None] | AsyncListener | Sequence[Callable] | None = None)
+
+
+
+ +Expand source code + +
def step(
+    self,
+    callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder],
+    edit: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+    save: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+    execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new step from app listener.
+
+    Unlike others, this method doesn't behave as a decorator.
+    If you want to register a step from app by a decorator, use `AsyncWorkflowStepBuilder`'s methods.
+
+        # Create a new WorkflowStep instance
+        from slack_bolt.workflows.async_step import AsyncWorkflowStep
+        ws = AsyncWorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        # Pass Step to set up listeners
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+    For further information about AsyncWorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        callback_id: The Callback ID for this step from app
+        edit: The function for displaying a modal in the Workflow Builder
+        save: The function for handling configuration in the Workflow Builder
+        execute: The function for handling the step execution
+    """
+    warnings.warn(
+        (
+            "Steps from apps for legacy workflows are now deprecated. "
+            "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+        ),
+        category=DeprecationWarning,
+    )
+    step = callback_id
+    if isinstance(callback_id, (str, Pattern)):
+        step = AsyncWorkflowStep(
+            callback_id=callback_id,
+            edit=edit,  # type: ignore[arg-type]
+            save=save,  # type: ignore[arg-type]
+            execute=execute,  # type: ignore[arg-type]
+            base_logger=self._base_logger,
+        )
+    elif isinstance(step, AsyncWorkflowStepBuilder):
+        step = step.build(base_logger=self._base_logger)
+    elif not isinstance(step, AsyncWorkflowStep):
+        raise BoltError(f"Invalid step object ({type(step)})")
+
+    self.use(AsyncWorkflowStepMiddleware(step))
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new step from app listener.

+

Unlike others, this method doesn't behave as a decorator. +If you want to register a step from app by a decorator, use AsyncWorkflowStepBuilder's methods.

+
# Create a new WorkflowStep instance
+from slack_bolt.workflows.async_step import AsyncWorkflowStep
+ws = AsyncWorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+# Pass Step to set up listeners
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document. +For further information about AsyncWorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to the async prefixed ones in slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
callback_id
+
The Callback ID for this step from app
+
edit
+
The function for displaying a modal in the Workflow Builder
+
save
+
The function for handling configuration in the Workflow Builder
+
execute
+
The function for handling the step execution
+
+
+
+def use(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def use(self, *args) -> Optional[Callable]:
+    """Refer to `AsyncApp#middleware()` method's docstring for details."""
+    return self.middleware(*args)
+
+

Refer to AsyncApp#middleware() method's docstring for details.

+
+
+def view(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def view(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `view_submission`/`view_closed` event listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.view("view_1")
+        async def handle_submission(ack, body, client, view):
+            # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+            hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+            user = body["user"]["id"]
+            # Validate the inputs
+            errors = {}
+            if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                errors["block_c"] = "The value must be longer than 5 characters"
+            if len(errors) > 0:
+                await ack(response_action="errors", errors=errors)
+                return
+            # Acknowledge the view_submission event and close the modal
+            await ack()
+            # Do whatever you want with the input data - here we're saving it to a DB
+
+        # Pass a function to this method
+        app.view("view_1")(handle_submission)
+
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission/view_closed event listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.view("view_1")
+async def handle_submission(ack, body, client, view):
+    # Assume there's an input block with <code>block\_c</code> as the block_id and <code>dreamy\_input</code>
+    hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+    user = body["user"]["id"]
+    # Validate the inputs
+    errors = {}
+    if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+        errors["block_c"] = "The value must be longer than 5 characters"
+    if len(errors) > 0:
+        await ack(response_action="errors", errors=errors)
+        return
+    # Acknowledge the view_submission event and close the modal
+    await ack()
+    # Do whatever you want with the input data - here we're saving it to a DB
+
+# Pass a function to this method
+app.view("view_1")(handle_submission)
+
+

Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def view_closed(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def view_closed(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `view_closed` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_closed(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+ +
+
+def view_submission(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def view_submission(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `view_submission` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+    details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_submission(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

+
+
+def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application +
+
+
+ +Expand source code + +
def web_app(self, path: str = "/slack/events", port: int = 3000) -> web.Application:
+    """Returns a `web.Application` instance for aiohttp-devtools users.
+
+        from slack_bolt.async_app import AsyncApp
+        app = AsyncApp()
+
+        @app.event("app_mention")
+        async def event_test(body, say, logger):
+            logger.info(body)
+            await say("What's up?")
+
+        def app_factory():
+            return app.web_app()
+
+        # adev runserver --port 3000 --app-factory app_factory async_app.py
+
+    Args:
+        path: The path to receive incoming requests from Slack
+        port: The port to listen on (Default: 3000)
+    """
+    return self.server(path=path, port=port).web_app
+
+

Returns a web.Application instance for aiohttp-devtools users.

+
from slack_bolt.async_app import AsyncApp
+app = AsyncApp()
+
+@app.event("app_mention")
+async def event_test(body, say, logger):
+    logger.info(body)
+    await say("What's up?")
+
+def app_factory():
+    return app.web_app()
+
+# adev runserver --port 3000 --app-factory app_factory async_app.py
+
+

Args

+
+
path
+
The path to receive incoming requests from Slack
+
port
+
The port to listen on (Default: 3000)
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/app/async_server.html b/docs/reference/app/async_server.html new file mode 100644 index 000000000..5eefe90dd --- /dev/null +++ b/docs/reference/app/async_server.html @@ -0,0 +1,276 @@ + + + + + + +slack_bolt.app.async_server API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.app.async_server

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSlackAppServer +(port: int, path: str, app: AsyncApp, host: str | None = None) +
+
+
+ +Expand source code + +
class AsyncSlackAppServer:
+    port: int
+    path: str
+    host: str
+    bolt_app: "AsyncApp"
+    web_app: web.Application
+
+    def __init__(
+        self,
+        port: int,
+        path: str,
+        app: "AsyncApp",
+        host: Optional[str] = None,
+    ):
+        """Standalone AIOHTTP Web Server.
+        Refer to https://docs.aiohttp.org/en/stable/web.html for details of AIOHTTP.
+
+        Args:
+            port: The port to listen on
+            path: The path to receive incoming requests from Slack
+            app: The `AsyncApp` instance that is used for processing requests
+            host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+        """
+        self.port = port
+        self.path = path
+        self.host = host if host is not None else "0.0.0.0"
+        self.bolt_app: "AsyncApp" = app
+        self.web_app = web.Application()
+        self._bolt_oauth_flow = self.bolt_app.oauth_flow
+        if self._bolt_oauth_flow:
+            self.web_app.add_routes(
+                [
+                    web.get(self._bolt_oauth_flow.install_path, self.handle_get_requests),
+                    web.get(
+                        self._bolt_oauth_flow.redirect_uri_path,
+                        self.handle_get_requests,
+                    ),
+                    web.post(self.path, self.handle_post_requests),
+                ]
+            )
+        else:
+            self.web_app.add_routes([web.post(self.path, self.handle_post_requests)])
+
+    async def handle_get_requests(self, request: web.Request) -> web.Response:
+        oauth_flow = self._bolt_oauth_flow
+        if oauth_flow:
+            if request.path == oauth_flow.install_path:
+                bolt_req = await to_bolt_request(request)
+                bolt_resp = await oauth_flow.handle_installation(bolt_req)
+                return await to_aiohttp_response(bolt_resp)
+            elif request.path == oauth_flow.redirect_uri_path:
+                bolt_req = await to_bolt_request(request)
+                bolt_resp = await oauth_flow.handle_callback(bolt_req)
+                return await to_aiohttp_response(bolt_resp)
+            else:
+                return web.Response(status=404)
+        else:
+            return web.Response(status=404)
+
+    async def handle_post_requests(self, request: web.Request) -> web.Response:
+        if self.path != request.path:
+            return web.Response(status=404)
+
+        bolt_req = await to_bolt_request(request)
+        bolt_resp: BoltResponse = await self.bolt_app.async_dispatch(bolt_req)
+        return await to_aiohttp_response(bolt_resp)
+
+    def start(self, host: Optional[str] = None) -> None:
+        """Starts a new web server process."""
+        if self.bolt_app.logger.level > logging.INFO:
+            print(get_boot_message())
+        else:
+            self.bolt_app.logger.info(get_boot_message())
+
+        _host = host if host is not None else self.host
+        web.run_app(self.web_app, host=_host, port=self.port)
+
+

Standalone AIOHTTP Web Server. +Refer to https://docs.aiohttp.org/en/stable/web.html for details of AIOHTTP.

+

Args

+
+
port
+
The port to listen on
+
path
+
The path to receive incoming requests from Slack
+
app
+
The AsyncApp instance that is used for processing requests
+
host
+
The hostname to serve the web endpoints. (Default: 0.0.0.0)
+
+

Class variables

+
+
var bolt_app : AsyncApp
+
+

The type of the None singleton.

+
+
var host : str
+
+

The type of the None singleton.

+
+
var path : str
+
+

The type of the None singleton.

+
+
var port : int
+
+

The type of the None singleton.

+
+
var web_app : aiohttp.web_app.Application
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def handle_get_requests(self, request: aiohttp.web_request.Request) ‑> aiohttp.web_response.Response +
+
+
+ +Expand source code + +
async def handle_get_requests(self, request: web.Request) -> web.Response:
+    oauth_flow = self._bolt_oauth_flow
+    if oauth_flow:
+        if request.path == oauth_flow.install_path:
+            bolt_req = await to_bolt_request(request)
+            bolt_resp = await oauth_flow.handle_installation(bolt_req)
+            return await to_aiohttp_response(bolt_resp)
+        elif request.path == oauth_flow.redirect_uri_path:
+            bolt_req = await to_bolt_request(request)
+            bolt_resp = await oauth_flow.handle_callback(bolt_req)
+            return await to_aiohttp_response(bolt_resp)
+        else:
+            return web.Response(status=404)
+    else:
+        return web.Response(status=404)
+
+
+
+
+async def handle_post_requests(self, request: aiohttp.web_request.Request) ‑> aiohttp.web_response.Response +
+
+
+ +Expand source code + +
async def handle_post_requests(self, request: web.Request) -> web.Response:
+    if self.path != request.path:
+        return web.Response(status=404)
+
+    bolt_req = await to_bolt_request(request)
+    bolt_resp: BoltResponse = await self.bolt_app.async_dispatch(bolt_req)
+    return await to_aiohttp_response(bolt_resp)
+
+
+
+
+def start(self, host: str | None = None) ‑> None +
+
+
+ +Expand source code + +
def start(self, host: Optional[str] = None) -> None:
+    """Starts a new web server process."""
+    if self.bolt_app.logger.level > logging.INFO:
+        print(get_boot_message())
+    else:
+        self.bolt_app.logger.info(get_boot_message())
+
+    _host = host if host is not None else self.host
+    web.run_app(self.web_app, host=_host, port=self.port)
+
+

Starts a new web server process.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/app/index.html b/docs/reference/app/index.html new file mode 100644 index 000000000..5581b98e7 --- /dev/null +++ b/docs/reference/app/index.html @@ -0,0 +1,3128 @@ + + + + + + +slack_bolt.app API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.app

+
+
+

Application interface in Bolt.

+

For most use cases, we recommend using slack_bolt.app.app. +If you already have knowledge about asyncio and prefer the programming model, +you can use slack_bolt.app.async_app for building async apps.

+
+
+

Sub-modules

+
+
slack_bolt.app.app
+
+
+
+
slack_bolt.app.async_app
+
+
+
+
slack_bolt.app.async_server
+
+
+
+
+
+
+
+
+
+
+

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,
attaching_conversation_kwargs_enabled: bool = True)
+
+
+
+ +Expand source code + +
class App:
+    def __init__(
+        self,
+        *,
+        logger: Optional[logging.Logger] = None,
+        # Used in logger
+        name: Optional[str] = None,
+        # Set True when you run this app on a FaaS platform
+        process_before_response: bool = False,
+        # Set True if you want to handle an unhandled request as an exception
+        raise_error_for_unhandled_request: bool = False,
+        # Basic Information > Credentials > Signing Secret
+        signing_secret: Optional[str] = None,
+        # for single-workspace apps
+        token: Optional[str] = None,
+        token_verification_enabled: bool = True,
+        client: Optional[WebClient] = None,
+        # for multi-workspace apps
+        before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None,
+        authorize: Optional[Callable[..., AuthorizeResult]] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+        installation_store: Optional[InstallationStore] = None,
+        # for either only bot scope usage or v1.0.x compatibility
+        installation_store_bot_only: Optional[bool] = None,
+        # for customizing the built-in middleware
+        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,
+        # for the OAuth flow
+        oauth_settings: Optional[OAuthSettings] = None,
+        oauth_flow: Optional[OAuthFlow] = None,
+        # No need to set (the value is used only in response to ssl_check requests)
+        verification_token: Optional[str] = None,
+        # Set this one only when you want to customize the executor
+        listener_executor: Optional[Executor] = None,
+        # 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.
+
+            import os
+            from slack_bolt import App
+
+            # Initializes your app with your bot token and signing secret
+            app = App(
+                token=os.environ.get("SLACK_BOT_TOKEN"),
+                signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+            )
+
+            # Listens to incoming messages that contain "hello"
+            @app.message("hello")
+            def message_hello(message, say):
+                # say() sends a message to the channel where the event was triggered
+                say(f"Hey there <@{message['user']}>!")
+
+            # Start your app
+            if __name__ == "__main__":
+                app.start(port=int(os.environ.get("PORT", 3000)))
+
+        Refer to https://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:
+            logger: The custom logger that can be used in this app.
+            name: The application name that will be used in logging. If absent, the source file name will be used.
+            process_before_response: True if this app runs on Function as a Service. (Default: False)
+            raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
+                and use @app.error listeners instead of
+                the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+            signing_secret: The Signing Secret value used for verifying requests from Slack.
+            token: The bot/user access token required only for single-workspace app.
+            token_verification_enabled: Verifies the validity of the given token if True.
+            client: The singleton `slack_sdk.WebClient` instance for this app.
+            before_authorize: A global middleware that can be executed right before authorize function
+            authorize: The function to authorize an incoming request from Slack
+                by checking if there is a team/user in the installation data.
+            user_facing_authorize_error_message: The user-facing error message to display
+                when the app is installed but the installation is not managed by this app's installation store
+            installation_store: The module offering save/find operations of installation data
+            installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False)
+            request_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests.
+                Make sure if it's safe enough when you turn a built-in middleware off.
+                We strongly recommend using RequestVerification for better security.
+                If you have a proxy that verifies request signature in front of the Bolt app,
+                it's totally fine to disable RequestVerification to avoid duplication of work.
+                Don't turn it off just for easiness of development.
+            ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events
+                generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+            ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware.
+                `IgnoringSelfEvents` for this app's bot user message events within an assistant thread
+                This is useful for avoiding code error causing an infinite loop; Default: True
+            url_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `UrlVerification` is a built-in middleware that handles url_verification requests
+                that verify the endpoint for Events API in HTTP Mode requests.
+            attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution tokens
+                when your app receives `function_executed` or interactivity events scoped to a custom step.
+            ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True).
+                `SslCheck` is a built-in middleware that handles ssl_check requests from Slack.
+            oauth_settings: The settings related to Slack app installation flow (OAuth flow)
+            oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings.
+            verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests.
+            listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will
+                be used.
+            assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation,
+                which uses a parent message's metadata to store the latest context)
+        """
+        if signing_secret is None:
+            signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "")
+        token = token or os.environ.get("SLACK_BOT_TOKEN")
+
+        self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1]
+        self._signing_secret: str = signing_secret
+
+        self._verification_token: Optional[str] = verification_token or os.environ.get("SLACK_VERIFICATION_TOKEN", None)
+        # If a logger is explicitly passed when initializing, the logger works as the base logger.
+        # The base logger's logging settings will be propagated to all the loggers created by bolt-python.
+        self._base_logger = logger
+        # The framework logger is supposed to be used for the internal logging.
+        # Also, it's accessible via `app.logger` as the app's singleton logger.
+        self._framework_logger = logger or get_bolt_logger(App)
+        self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
+
+        self._token: Optional[str] = token
+
+        if client is not None:
+            if not isinstance(client, WebClient):
+                raise BoltError(error_client_invalid_type())
+            self._client = client
+            self._token = client.token
+            if token is not None:
+                self._framework_logger.warning(warning_client_prioritized_and_token_skipped())
+        else:
+            self._client = create_web_client(
+                # NOTE: the token here can be None
+                token=token,
+                logger=self._framework_logger,
+            )
+
+        # --------------------------------------
+        # Authorize & OAuthFlow initialization
+        # --------------------------------------
+
+        self._before_authorize: Optional[Middleware] = None
+        if before_authorize is not None:
+            if callable(before_authorize):
+                self._before_authorize = CustomMiddleware(
+                    app_name=self._name,
+                    func=before_authorize,
+                    base_logger=self._framework_logger,
+                )
+            elif isinstance(before_authorize, Middleware):
+                self._before_authorize = before_authorize
+
+        self._authorize: Optional[Authorize] = None
+        if authorize is not None:
+            if isinstance(authorize, Authorize):
+                # As long as an advanced developer understands what they're doing,
+                # bolt-python should not prevent customizing authorize middleware
+                self._authorize = authorize
+            else:
+                if oauth_settings is not None or oauth_flow is not None:
+                    # If the given authorize is a simple function,
+                    # it does not work along with installation_store.
+                    raise BoltError(error_authorize_conflicts())
+                self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize)
+
+        self._installation_store: Optional[InstallationStore] = installation_store
+        if self._installation_store is not None and self._authorize is None:
+            settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
+            self._authorize = InstallationStoreAuthorize(
+                installation_store=self._installation_store,
+                client_id=settings.client_id if settings is not None else None,
+                client_secret=settings.client_secret if settings is not None else None,
+                logger=self._framework_logger,
+                bot_only=installation_store_bot_only or False,
+                client=self._client,  # for proxy use cases etc.
+                user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"),
+            )
+
+        self._oauth_flow: Optional[OAuthFlow] = None
+
+        if (
+            oauth_settings is None
+            and os.environ.get("SLACK_CLIENT_ID") is not None
+            and os.environ.get("SLACK_CLIENT_SECRET") is not None
+        ):
+            # initialize with the default settings
+            oauth_settings = OAuthSettings()
+
+            if oauth_flow is None and installation_store is None:
+                # show info-level log for avoiding confusions
+                self._framework_logger.info(info_default_oauth_settings_loaded())
+
+        if oauth_flow is not None:
+            self._oauth_flow = oauth_flow
+            installation_store = select_consistent_installation_store(
+                client_id=self._oauth_flow.client_id,
+                app_store=self._installation_store,
+                oauth_flow_store=self._oauth_flow.settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._installation_store = installation_store
+            if installation_store is not None:
+                self._oauth_flow.settings.installation_store = installation_store
+
+            if self._oauth_flow._client is None:
+                self._oauth_flow._client = self._client
+            if self._authorize is None:
+                self._authorize = self._oauth_flow.settings.authorize
+        elif oauth_settings is not None:
+            installation_store = select_consistent_installation_store(
+                client_id=oauth_settings.client_id,
+                app_store=self._installation_store,
+                oauth_flow_store=oauth_settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._installation_store = installation_store
+            if installation_store is not None:
+                oauth_settings.installation_store = installation_store
+            self._oauth_flow = OAuthFlow(client=self.client, logger=self.logger, settings=oauth_settings)
+            if self._authorize is None:
+                self._authorize = self._oauth_flow.settings.authorize
+            self._authorize.token_rotation_expiration_minutes = oauth_settings.token_rotation_expiration_minutes  # type: ignore[attr-defined] # noqa: E501
+
+        if (self._installation_store is not None or self._authorize is not None) and self._token is not None:
+            self._token = None
+            self._framework_logger.warning(warning_token_skipped())
+
+        # after setting bot_only here, __init__ cannot replace authorize function
+        if installation_store_bot_only is not None and self._oauth_flow is not None:
+            app_bot_only = installation_store_bot_only or False
+            oauth_flow_bot_only = self._oauth_flow.settings.installation_store_bot_only
+            if app_bot_only != oauth_flow_bot_only:
+                self.logger.warning(warning_bot_only_conflicts())
+                self._oauth_flow.settings.installation_store_bot_only = app_bot_only
+                self._authorize.bot_only = app_bot_only  # type: ignore[union-attr]
+
+        self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None
+        if self._installation_store is not None:
+            self._tokens_revocation_listeners = TokenRevocationListeners(self._installation_store)
+
+        # --------------------------------------
+        # Middleware Initialization
+        # --------------------------------------
+
+        self._middleware_list: List[Middleware] = []
+        self._listeners: List[Listener] = []
+
+        if listener_executor is None:
+            listener_executor = ThreadPoolExecutor(max_workers=5)
+
+        self._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(
+            logger=self._framework_logger,
+            process_before_response=process_before_response,
+            listener_error_handler=DefaultListenerErrorHandler(logger=self._framework_logger),
+            listener_start_handler=DefaultListenerStartHandler(logger=self._framework_logger),
+            listener_completion_handler=DefaultListenerCompletionHandler(logger=self._framework_logger),
+            listener_executor=listener_executor,
+            lazy_listener_runner=ThreadLazyListenerRunner(
+                logger=self._framework_logger,
+                executor=listener_executor,
+            ),
+        )
+        self._middleware_error_handler: MiddlewareErrorHandler = DefaultMiddlewareErrorHandler(
+            logger=self._framework_logger,
+        )
+
+        self._init_middleware_list_done = False
+        self._init_middleware_list(
+            token_verification_enabled=token_verification_enabled,
+            request_verification_enabled=request_verification_enabled,
+            ignoring_self_events_enabled=ignoring_self_events_enabled,
+            ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+            ssl_check_enabled=ssl_check_enabled,
+            url_verification_enabled=url_verification_enabled,
+            attaching_function_token_enabled=attaching_function_token_enabled,
+            user_facing_authorize_error_message=user_facing_authorize_error_message,
+        )
+
+    def _init_middleware_list(
+        self,
+        token_verification_enabled: bool = True,
+        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,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        if self._init_middleware_list_done:
+            return
+        if ssl_check_enabled is True:
+            self._middleware_list.append(
+                SslCheck(
+                    verification_token=self._verification_token,
+                    base_logger=self._base_logger,
+                )
+            )
+        if request_verification_enabled is True:
+            self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger))
+
+        if self._before_authorize is not None:
+            self._middleware_list.append(self._before_authorize)
+
+        # As authorize is required for making a Bolt app function, we don't offer the flag to disable this
+        if self._oauth_flow is None:
+            if self._token is not None:
+                try:
+                    auth_test_result = None
+                    if token_verification_enabled:
+                        # This API call is for eagerly validating the token
+                        auth_test_result = self._client.auth_test(token=self._token)
+                    self._middleware_list.append(
+                        SingleTeamAuthorization(
+                            auth_test_result=auth_test_result,
+                            base_logger=self._base_logger,
+                            user_facing_authorize_error_message=user_facing_authorize_error_message,
+                        )
+                    )
+                except SlackApiError as err:
+                    raise BoltError(error_auth_test_failure(err.response))
+            elif self._authorize is not None:
+                self._middleware_list.append(
+                    MultiTeamsAuthorization(
+                        authorize=self._authorize,
+                        base_logger=self._base_logger,
+                        user_facing_authorize_error_message=user_facing_authorize_error_message,
+                    )
+                )
+            else:
+                raise BoltError(error_token_required())
+        elif self._authorize is not None:
+            self._middleware_list.append(
+                MultiTeamsAuthorization(
+                    authorize=self._authorize,
+                    base_logger=self._base_logger,
+                    user_token_resolution=self._oauth_flow.settings.user_token_resolution,
+                    user_facing_authorize_error_message=user_facing_authorize_error_message,
+                )
+            )
+        else:
+            raise BoltError(error_oauth_flow_or_authorize_required())
+
+        if ignoring_self_events_enabled is True:
+            self._middleware_list.append(
+                IgnoringSelfEvents(
+                    base_logger=self._base_logger,
+                    ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+                )
+            )
+        if url_verification_enabled is True:
+            self._middleware_list.append(UrlVerification(base_logger=self._base_logger))
+        if attaching_function_token_enabled is True:
+            self._middleware_list.append(AttachingFunctionToken())
+        self._init_middleware_list_done = True
+
+    # -------------------------
+    # accessors
+
+    @property
+    def name(self) -> str:
+        """The name of this app (default: the filename)"""
+        return self._name
+
+    @property
+    def oauth_flow(self) -> Optional[OAuthFlow]:
+        """Configured `OAuthFlow` object if exists."""
+        return self._oauth_flow
+
+    @property
+    def logger(self) -> logging.Logger:
+        """The logger this app uses."""
+        return self._framework_logger
+
+    @property
+    def client(self) -> WebClient:
+        """The singleton `slack_sdk.WebClient` instance in this app."""
+        return self._client
+
+    @property
+    def installation_store(self) -> Optional[InstallationStore]:
+        """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
+        return self._installation_store
+
+    @property
+    def listener_runner(self) -> ThreadListenerRunner:
+        """The thread executor for asynchronously running listeners."""
+        return self._listener_runner
+
+    @property
+    def process_before_response(self) -> bool:
+        return self._process_before_response or False
+
+    # -------------------------
+    # standalone server
+
+    def start(
+        self,
+        port: int = 3000,
+        path: str = "/slack/events",
+        http_server_logger_enabled: bool = True,
+    ) -> None:
+        """Starts a web server for local development.
+
+            # With the default settings, `http://localhost:3000/slack/events`
+            # is available for handling incoming requests from Slack
+            app.start()
+
+        This method internally starts a Web server process built with the `http.server` module.
+        For production, consider using a production-ready WSGI server such as Gunicorn.
+
+        Args:
+            port: The port to listen on (Default: 3000)
+            path: The path to handle request from Slack (Default: `/slack/events`)
+            http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
+        """
+        self._development_server = SlackAppDevelopmentServer(
+            port=port,
+            path=path,
+            app=self,
+            oauth_flow=self.oauth_flow,
+            http_server_logger_enabled=http_server_logger_enabled,
+        )
+        self._development_server.start()
+
+    # -------------------------
+    # main dispatcher
+
+    def dispatch(self, req: BoltRequest) -> BoltResponse:
+        """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+        Args:
+            req: An incoming request from Slack
+
+        Returns:
+            The response generated by this Bolt app
+        """
+        starting_time = time.time()
+        self._init_context(req)
+
+        resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+        middleware_state = {"next_called": False}
+
+        def middleware_next():
+            middleware_state["next_called"] = True
+
+        try:
+            for middleware in self._middleware_list:
+                middleware_state["next_called"] = False
+                if self._framework_logger.level <= logging.DEBUG:
+                    self._framework_logger.debug(debug_applying_middleware(middleware.name))
+                resp = middleware.process(req=req, resp=resp, next=middleware_next)  # type: ignore[arg-type]
+                if not middleware_state["next_called"]:
+                    if resp is None:
+                        # next() method was not called without providing the response to return to Slack
+                        # This should not be an intentional handling in usual use cases.
+                        resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                        if self._raise_error_for_unhandled_request is True:
+                            try:
+                                raise BoltUnhandledRequestError(
+                                    request=req,
+                                    current_response=resp,
+                                    last_global_middleware_name=middleware.name,
+                                )
+                            except BoltUnhandledRequestError as e:
+                                self._listener_runner.listener_error_handler.handle(
+                                    error=e,
+                                    request=req,
+                                    response=resp,
+                                )
+                            return resp
+                        self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                        return resp
+                    return resp
+
+            for listener in self._listeners:
+                listener_name = get_name_for_callable(listener.ack_function)
+                self._framework_logger.debug(debug_checking_listener(listener_name))
+                if listener.matches(req=req, resp=resp):  # type: ignore[arg-type]
+                    # run all the middleware attached to this listener first
+                    middleware_resp, next_was_not_called = listener.run_middleware(
+                        req=req, resp=resp  # type: ignore[arg-type]
+                    )
+                    if next_was_not_called:
+                        if middleware_resp is not None:
+                            if self._framework_logger.level <= logging.DEBUG:
+                                debug_message = debug_return_listener_middleware_response(
+                                    listener_name,
+                                    middleware_resp.status,
+                                    middleware_resp.body,
+                                    starting_time,
+                                )
+                                self._framework_logger.debug(debug_message)
+                            return middleware_resp
+                        # The last listener middleware didn't call next() method.
+                        # This means the listener is not for this incoming request.
+                        continue
+
+                    if middleware_resp is not None:
+                        resp = middleware_resp
+
+                    self._framework_logger.debug(debug_running_listener(listener_name))
+                    listener_response: Optional[BoltResponse] = self._listener_runner.run(
+                        request=req,
+                        response=resp,  # type: ignore[arg-type]
+                        listener_name=listener_name,
+                        listener=listener,
+                    )
+                    if listener_response is not None:
+                        return listener_response
+
+            if resp is None:
+                resp = BoltResponse(status=404, body={"error": "unhandled request"})
+            if self._raise_error_for_unhandled_request is True:
+                try:
+                    raise BoltUnhandledRequestError(
+                        request=req,
+                        current_response=resp,
+                    )
+                except BoltUnhandledRequestError as e:
+                    self._listener_runner.listener_error_handler.handle(
+                        error=e,
+                        request=req,
+                        response=resp,
+                    )
+                return resp
+            return self._handle_unmatched_requests(req, resp)
+        except Exception as error:
+            resp = BoltResponse(status=500, body="")
+            self._middleware_error_handler.handle(
+                error=error,
+                request=req,
+                response=resp,
+            )
+            return resp
+
+    def _handle_unmatched_requests(self, req: BoltRequest, resp: BoltResponse) -> BoltResponse:
+        self._framework_logger.warning(warning_unhandled_request(req))
+        return resp
+
+    # -------------------------
+    # middleware
+
+    def use(self, *args) -> Optional[Callable]:
+        """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
+
+        Refer to `App#middleware()` method's docstring for details."""
+        return self.middleware(*args)
+
+    def middleware(self, *args) -> Optional[Callable]:
+        """Registers a new middleware to this app.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.middleware
+            def middleware_func(logger, body, next):
+                logger.info(f"request body: {body}")
+                next()
+
+            # Pass a function to this method
+            app.middleware(middleware_func)
+
+        Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            *args: A function that works as a global middleware.
+        """
+        if len(args) > 0:
+            middleware_or_callable = args[0]
+            if isinstance(middleware_or_callable, Middleware):
+                middleware: Middleware = middleware_or_callable
+                self._middleware_list.append(middleware)
+                if isinstance(middleware, Assistant) and middleware.thread_context_store is not None:
+                    self._assistant_thread_context_store = middleware.thread_context_store
+            elif callable(middleware_or_callable):
+                self._middleware_list.append(
+                    CustomMiddleware(
+                        app_name=self.name,
+                        func=middleware_or_callable,
+                        base_logger=self._base_logger,
+                    )
+                )
+                return middleware_or_callable
+            else:
+                raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+        return None
+
+    # -------------------------
+    # AI Agents & Assistants
+
+    def assistant(self, assistant: Assistant) -> Optional[Callable]:
+        return self.middleware(assistant)
+
+    # -------------------------
+    # Workflows: Steps from apps
+
+    def step(
+        self,
+        callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
+        edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+        save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+        execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new step from app listener.
+
+        Unlike others, this method doesn't behave as a decorator.
+        If you want to register a step from app by a decorator, use `WorkflowStepBuilder`'s methods.
+
+            # Create a new WorkflowStep instance
+            from slack_bolt.workflows.step import WorkflowStep
+            ws = WorkflowStep(
+                callback_id="add_task",
+                edit=edit,
+                save=save,
+                execute=execute,
+            )
+            # Pass Step to set up listeners
+            app.step(ws)
+
+        Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        For further information about WorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            callback_id: The Callback ID for this step from app
+            edit: The function for displaying a modal in the Workflow Builder
+            save: The function for handling configuration in the Workflow Builder
+            execute: The function for handling the step execution
+        """
+        warnings.warn(
+            (
+                "Steps from apps for legacy workflows are now deprecated. "
+                "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+            ),
+            category=DeprecationWarning,
+        )
+        step = callback_id
+        if isinstance(callback_id, (str, Pattern)):
+            step = WorkflowStep(
+                callback_id=callback_id,
+                edit=edit,  # type: ignore[arg-type]
+                save=save,  # type: ignore[arg-type]
+                execute=execute,  # type: ignore[arg-type]
+                base_logger=self._base_logger,
+            )
+        elif isinstance(step, WorkflowStepBuilder):
+            step = step.build(base_logger=self._base_logger)
+        elif not isinstance(step, WorkflowStep):
+            raise BoltError(f"Invalid step object ({type(step)})")
+
+        self.use(WorkflowStepMiddleware(step))
+
+    # -------------------------
+    # global error handler
+
+    def error(self, func: Callable[..., Optional[BoltResponse]]) -> Callable[..., Optional[BoltResponse]]:
+        """Updates the global error handler. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.error
+            def custom_error_handler(error, body, logger):
+                logger.exception(f"Error: {error}")
+                logger.info(f"Request body: {body}")
+
+            # Pass a function to this method
+            app.error(custom_error_handler)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            func: The function that is supposed to be executed
+                when getting an unhandled error in Bolt app.
+        """
+        self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        self._middleware_error_handler = CustomMiddlewareErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        return func
+
+    # -------------------------
+    # events
+
+    def event(
+        self,
+        event: Union[
+            str,
+            Pattern,
+            Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+        ],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new event listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.event("team_join")
+            def ask_for_introduction(event, say):
+                welcome_channel_id = "C12345"
+                user_id = event["user"]
+                text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+                say(text=text, channel=welcome_channel_id)
+
+            # Pass a function to this method
+            app.event("team_join")(ask_for_introduction)
+
+        Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            event: The conditions that match a request payload.
+                If you pass a dict for this, you can have type, subtype in the constraint.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        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__
+
+    def message(
+        self,
+        keyword: Union[str, Pattern] = "",
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new message event listener. This method can be used as either a decorator or a method.
+        Check the `App#event` method's docstring for details.
+
+            # Use this method as a decorator
+            @app.message(":wave:")
+            def say_hello(message, say):
+                user = message['user']
+                say(f"Hi there, <@{user}>!")
+
+            # Pass a function to this method
+            app.message(":wave:")(say_hello)
+
+        Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            keyword: The keyword to match
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            constraints = {
+                "type": "message",
+                "subtype": (
+                    # In most cases, new message events come with no subtype.
+                    None,
+                    # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                    # By contrast, messages posted using classic app's bot token still have the subtype.
+                    "bot_message",
+                    # If an end-user posts a message with "Also send to #channel" checked,
+                    # the message event comes with this subtype.
+                    "thread_broadcast",
+                    # If an end-user posts a message with attached files,
+                    # the message event comes with this subtype.
+                    "file_share",
+                ),
+            }
+            primary_matcher = builtin_matchers.message_event(
+                keyword=keyword, constraints=constraints, base_logger=self._base_logger
+            )
+            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)
+
+        return __call__
+
+    def function(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+        auto_acknowledge: bool = True,
+        ack_timeout: int = 3,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new Function listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.function("reverse")
+            def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+                try:
+                    ack()
+                    string_to_reverse = inputs["stringToReverse"]
+                    complete(outputs={"reverseString": string_to_reverse[::-1]})
+                except Exception as e:
+                    fail(f"Cannot reverse string (error: {e})")
+                    raise e
+
+            # Pass a function to this method
+            app.function("reverse")(reverse_string)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            callback_id: The callback id to identify the function
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        if auto_acknowledge is True:
+            if ack_timeout != 3:
+                self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger)
+            return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+        return __call__
+
+    # -------------------------
+    # slash commands
+
+    def command(
+        self,
+        command: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new slash command listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.command("/echo")
+            def repeat_text(ack, say, command):
+                # Acknowledge command request
+                ack()
+                say(f"{command['text']}")
+
+            # Pass a function to this method
+            app.command("/echo")(repeat_text)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            command: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # shortcut
+
+    def shortcut(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new shortcut listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.shortcut("open_modal")
+            def open_modal(ack, body, client):
+                # Acknowledge the command request
+                ack()
+                # Call views_open with the built-in client
+                client.views_open(
+                    # Pass a valid trigger_id within 3 seconds of receiving it
+                    trigger_id=body["trigger_id"],
+                    # View payload
+                    view={ ... }
+                )
+
+            # Pass a function to this method
+            app.shortcut("open_modal")(open_modal)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.shortcut(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def global_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new global shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.global_shortcut(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def message_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new message shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.message_shortcut(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # action
+
+    def action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new action listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.action("approve_button")
+            def update_message(ack):
+                ack()
+
+            # Pass a function to this method
+            app.action("approve_button")(update_message)
+
+        * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+        * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+        * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.action(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `block_actions` action listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_action(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def attachment_action(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `interactive_message` action listener.
+        Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.attachment_action(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_submission(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_submission` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_submission(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_cancellation(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_cancellation` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_cancellation(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # view
+
+    def view(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_submission`/`view_closed` event listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.view("view_1")
+            def handle_submission(ack, body, client, view):
+                # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+                hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+                user = body["user"]["id"]
+                # Validate the inputs
+                errors = {}
+                if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                    errors["block_c"] = "The value must be longer than 5 characters"
+                if len(errors) > 0:
+                    ack(response_action="errors", errors=errors)
+                    return
+                # Acknowledge the view_submission event and close the modal
+                ack()
+                # Do whatever you want with the input data - here we're saving it to a DB
+
+            # Pass a function to this method
+            app.view("view_1")(handle_submission)
+
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_submission(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_submission` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+        details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_submission(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_closed(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_closed` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # options
+
+    def options(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new options listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.options("menu_selection")
+            def show_menu_options(ack):
+                options = [
+                    {
+                        "text": {"type": "plain_text", "text": "Option 1"},
+                        "value": "1-1",
+                    },
+                    {
+                        "text": {"type": "plain_text", "text": "Option 2"},
+                        "value": "1-2",
+                    },
+                ]
+                ack(options=options)
+
+            # Pass a function to this method
+            app.options("menu_selection")(show_menu_options)
+
+        Refer to the following documents for details:
+
+        * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+        * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.options(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_suggestion(
+        self,
+        action_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `block_suggestion` listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_suggestion(action_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_suggestion(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_suggestion` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_suggestion(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # built-in listener functions
+
+    def default_tokens_revoked_event_listener(
+        self,
+    ) -> Callable[..., Optional[BoltResponse]]:
+        if self._tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._tokens_revocation_listeners.handle_tokens_revoked_events
+
+    def default_app_uninstalled_event_listener(
+        self,
+    ) -> Callable[..., Optional[BoltResponse]]:
+        if self._tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._tokens_revocation_listeners.handle_app_uninstalled_events
+
+    def enable_token_revocation_listeners(self) -> None:
+        self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+        self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+    # -------------------------
+
+    def _init_context(self, req: BoltRequest):
+        req.context["logger"] = get_bolt_app_logger(app_name=self.name, base_logger=self._base_logger)
+        req.context["token"] = self._token
+        # Prior to version 1.15, when the token is static, self._client was passed to `req.context`.
+        # The intention was to avoid creating a new instance per request
+        # in the interest of runtime performance/memory footprint optimization.
+        # However, developers may want to replace the token held by req.context.client in some situations.
+        # In this case, this behavior can result in thread-unsafe data modification on `self._client`.
+        # (`self._client` a.k.a. `app.client` is a singleton object per an App instance)
+        # Thus, we've changed the behavior to create a new instance per request regardless of token argument
+        # in the App initialization starting v1.15.
+        # The overhead brought by this change is slight so that we believe that it is ignorable in any cases.
+        client_per_request: WebClient = WebClient(
+            token=self._token,  # this can be None, and it can be set later on
+            base_url=self._client.base_url,
+            timeout=self._client.timeout,
+            ssl=self._client.ssl,
+            proxy=self._client.proxy,
+            headers=self._client.headers,
+            team_id=req.context.team_id,
+            logger=self._client.logger,
+            retry_handlers=self._client.retry_handlers.copy() if self._client.retry_handlers is not None else None,
+        )
+        req.context["client"] = client_per_request
+
+        # Most apps do not need this "listener_runner" instance.
+        # It is intended for apps that start lazy listeners from their custom global middleware.
+        req.context["listener_runner"] = self.listener_runner
+
+    @staticmethod
+    def _to_listener_functions(
+        kwargs: dict,
+    ) -> Optional[Sequence[Callable[..., Optional[BoltResponse]]]]:
+        if kwargs:
+            functions = [kwargs["ack"]]
+            for sub in kwargs["lazy"]:
+                functions.append(sub)
+            return functions
+        return None
+
+    def _register_listener(
+        self,
+        functions: Sequence[Callable[..., Optional[BoltResponse]]],
+        primary_matcher: ListenerMatcher,
+        matchers: Optional[Sequence[Callable[..., bool]]],
+        middleware: Optional[Sequence[Union[Callable, Middleware]]],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+    ) -> Optional[Callable[..., Optional[BoltResponse]]]:
+        value_to_return = None
+        if not isinstance(functions, list):
+            functions = list(functions)
+        if len(functions) == 1:
+            # In the case where the function is registered using decorator,
+            # the registration should return the original function.
+            value_to_return = functions[0]
+
+        listener_matchers: List[ListenerMatcher] = [
+            CustomListenerMatcher(app_name=self.name, func=f, base_logger=self._base_logger) for f in (matchers or [])
+        ]
+        listener_matchers.insert(0, primary_matcher)
+        listener_middleware = []
+        for m in middleware or []:
+            if isinstance(m, Middleware):
+                listener_middleware.append(m)
+            elif callable(m):
+                listener_middleware.append(CustomMiddleware(app_name=self.name, func=m, base_logger=self._base_logger))
+            else:
+                raise ValueError(error_unexpected_listener_middleware(type(m)))
+
+        self._listeners.append(
+            CustomListener(
+                app_name=self.name,
+                ack_function=functions.pop(0),
+                lazy_functions=functions,  # type: ignore[arg-type]
+                matchers=listener_matchers,
+                middleware=listener_middleware,
+                auto_acknowledgement=auto_acknowledgement,
+                ack_timeout=ack_timeout,
+                base_logger=self._base_logger,
+            )
+        )
+        return value_to_return
+
+

Bolt App that provides functionalities to register middleware/listeners.

+
import os
+from slack_bolt import App
+
+# Initializes your app with your bot token and signing secret
+app = App(
+    token=os.environ.get("SLACK_BOT_TOKEN"),
+    signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+)
+
+# Listens to incoming messages that contain "hello"
+@app.message("hello")
+def message_hello(message, say):
+    # say() sends a message to the channel where the event was triggered
+    say(f"Hey there <@{message['user']}>!")
+
+# Start your app
+if __name__ == "__main__":
+    app.start(port=int(os.environ.get("PORT", 3000)))
+
+

Refer to https://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

+
+
logger
+
The custom logger that can be used in this app.
+
name
+
The application name that will be used in logging. If absent, the source file name will be used.
+
process_before_response
+
True if this app runs on Function as a Service. (Default: False)
+
raise_error_for_unhandled_request
+
True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+
signing_secret
+
The Signing Secret value used for verifying requests from Slack.
+
token
+
The bot/user access token required only for single-workspace app.
+
token_verification_enabled
+
Verifies the validity of the given token if True.
+
client
+
The singleton slack_sdk.WebClient instance for this app.
+
before_authorize
+
A global middleware that can be executed right before authorize function
+
authorize
+
The function to authorize an incoming request from Slack +by checking if there is a team/user in the installation data.
+
user_facing_authorize_error_message
+
The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
+
installation_store
+
The module offering save/find operations of installation data
+
installation_store_bot_only
+
Use InstallationStore#find_bot() if True (Default: False)
+
request_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +RequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
+
ignoring_self_events_enabled
+
False if you would like to disable the built-in middleware (Default: True). +IgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+
ignoring_self_assistant_message_events_enabled
+
False if you would like to disable the built-in middleware. +IgnoringSelfEvents for this app's bot user message events within an assistant thread +This is useful for avoiding code error causing an infinite loop; Default: True
+
url_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +UrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
+
attaching_function_token_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AttachingFunctionToken is a built-in middleware that injects the just-in-time workflow-execution tokens +when your app receives function_executed or interactivity events scoped to a custom step.
+
ssl_check_enabled
+
bool = False if you would like to disable the built-in middleware (Default: True). +SslCheck is a built-in middleware that handles ssl_check requests from Slack.
+
oauth_settings
+
The settings related to Slack app installation flow (OAuth flow)
+
oauth_flow
+
Instantiated OAuthFlow. This is always prioritized over oauth_settings.
+
verification_token
+
Deprecated verification mechanism. This can be used only for ssl_check requests.
+
listener_executor
+
Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will +be used.
+
assistant_thread_context_store
+
Custom AssistantThreadContext store (Default: the built-in implementation, +which uses a parent message's metadata to store the latest context)
+
+

Instance variables

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    """The singleton `slack_sdk.WebClient` instance in this app."""
+    return self._client
+
+

The singleton slack_sdk.WebClient instance in this app.

+
+
prop installation_store : slack_sdk.oauth.installation_store.installation_store.InstallationStore | None
+
+
+ +Expand source code + +
@property
+def installation_store(self) -> Optional[InstallationStore]:
+    """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
+    return self._installation_store
+
+

The slack_sdk.oauth.InstallationStore that can be used in the authorize middleware.

+
+
prop listener_runnerThreadListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> ThreadListenerRunner:
+    """The thread executor for asynchronously running listeners."""
+    return self._listener_runner
+
+

The thread executor for asynchronously running listeners.

+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> logging.Logger:
+    """The logger this app uses."""
+    return self._framework_logger
+
+

The logger this app uses.

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this app (default: the filename)"""
+    return self._name
+
+

The name of this app (default: the filename)

+
+
prop oauth_flowOAuthFlow | None
+
+
+ +Expand source code + +
@property
+def oauth_flow(self) -> Optional[OAuthFlow]:
+    """Configured `OAuthFlow` object if exists."""
+    return self._oauth_flow
+
+

Configured OAuthFlow object if exists.

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

Methods

+
+
+def action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new action listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.action("approve_button")
+        def update_message(ack):
+            ack()
+
+        # Pass a function to this method
+        app.action("approve_button")(update_message)
+
+    * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+    * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+    * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.action(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.action("approve_button")
+def update_message(ack):
+    ack()
+
+# Pass a function to this method
+app.action("approve_button")(update_message)
+
+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def assistant(self,
assistant: Assistant) ‑> Callable | None
+
+
+
+ +Expand source code + +
def assistant(self, assistant: Assistant) -> Optional[Callable]:
+    return self.middleware(assistant)
+
+
+
+
+def attachment_action(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def attachment_action(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `interactive_message` action listener.
+    Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.attachment_action(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new interactive_message action listener. +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

+
+
+def block_action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def block_action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `block_actions` action listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_action(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_actions action listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

+
+
+def block_suggestion(self,
action_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def block_suggestion(
+    self,
+    action_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `block_suggestion` listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_suggestion(action_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_suggestion listener.

+
+
+def command(self,
command: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def command(
+    self,
+    command: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new slash command listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.command("/echo")
+        def repeat_text(ack, say, command):
+            # Acknowledge command request
+            ack()
+            say(f"{command['text']}")
+
+        # Pass a function to this method
+        app.command("/echo")(repeat_text)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        command: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new slash command listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.command("/echo")
+def repeat_text(ack, say, command):
+    # Acknowledge command request
+    ack()
+    say(f"{command['text']}")
+
+# Pass a function to this method
+app.command("/echo")(repeat_text)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
command
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def default_app_uninstalled_event_listener(self) ‑> Callable[..., BoltResponse | None] +
+
+
+ +Expand source code + +
def default_app_uninstalled_event_listener(
+    self,
+) -> Callable[..., Optional[BoltResponse]]:
+    if self._tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._tokens_revocation_listeners.handle_app_uninstalled_events
+
+
+
+
+def default_tokens_revoked_event_listener(self) ‑> Callable[..., BoltResponse | None] +
+
+
+ +Expand source code + +
def default_tokens_revoked_event_listener(
+    self,
+) -> Callable[..., Optional[BoltResponse]]:
+    if self._tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._tokens_revocation_listeners.handle_tokens_revoked_events
+
+
+
+
+def dialog_cancellation(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_cancellation(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_cancellation` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_cancellation(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_cancellation listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_submission(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_submission(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_submission` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_submission(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_submission listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_suggestion(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_suggestion(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_suggestion` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_suggestion(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_suggestion listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dispatch(self,
req: BoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def dispatch(self, req: BoltRequest) -> BoltResponse:
+    """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+    Args:
+        req: An incoming request from Slack
+
+    Returns:
+        The response generated by this Bolt app
+    """
+    starting_time = time.time()
+    self._init_context(req)
+
+    resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+    middleware_state = {"next_called": False}
+
+    def middleware_next():
+        middleware_state["next_called"] = True
+
+    try:
+        for middleware in self._middleware_list:
+            middleware_state["next_called"] = False
+            if self._framework_logger.level <= logging.DEBUG:
+                self._framework_logger.debug(debug_applying_middleware(middleware.name))
+            resp = middleware.process(req=req, resp=resp, next=middleware_next)  # type: ignore[arg-type]
+            if not middleware_state["next_called"]:
+                if resp is None:
+                    # next() method was not called without providing the response to return to Slack
+                    # This should not be an intentional handling in usual use cases.
+                    resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                    if self._raise_error_for_unhandled_request is True:
+                        try:
+                            raise BoltUnhandledRequestError(
+                                request=req,
+                                current_response=resp,
+                                last_global_middleware_name=middleware.name,
+                            )
+                        except BoltUnhandledRequestError as e:
+                            self._listener_runner.listener_error_handler.handle(
+                                error=e,
+                                request=req,
+                                response=resp,
+                            )
+                        return resp
+                    self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                    return resp
+                return resp
+
+        for listener in self._listeners:
+            listener_name = get_name_for_callable(listener.ack_function)
+            self._framework_logger.debug(debug_checking_listener(listener_name))
+            if listener.matches(req=req, resp=resp):  # type: ignore[arg-type]
+                # run all the middleware attached to this listener first
+                middleware_resp, next_was_not_called = listener.run_middleware(
+                    req=req, resp=resp  # type: ignore[arg-type]
+                )
+                if next_was_not_called:
+                    if middleware_resp is not None:
+                        if self._framework_logger.level <= logging.DEBUG:
+                            debug_message = debug_return_listener_middleware_response(
+                                listener_name,
+                                middleware_resp.status,
+                                middleware_resp.body,
+                                starting_time,
+                            )
+                            self._framework_logger.debug(debug_message)
+                        return middleware_resp
+                    # The last listener middleware didn't call next() method.
+                    # This means the listener is not for this incoming request.
+                    continue
+
+                if middleware_resp is not None:
+                    resp = middleware_resp
+
+                self._framework_logger.debug(debug_running_listener(listener_name))
+                listener_response: Optional[BoltResponse] = self._listener_runner.run(
+                    request=req,
+                    response=resp,  # type: ignore[arg-type]
+                    listener_name=listener_name,
+                    listener=listener,
+                )
+                if listener_response is not None:
+                    return listener_response
+
+        if resp is None:
+            resp = BoltResponse(status=404, body={"error": "unhandled request"})
+        if self._raise_error_for_unhandled_request is True:
+            try:
+                raise BoltUnhandledRequestError(
+                    request=req,
+                    current_response=resp,
+                )
+            except BoltUnhandledRequestError as e:
+                self._listener_runner.listener_error_handler.handle(
+                    error=e,
+                    request=req,
+                    response=resp,
+                )
+            return resp
+        return self._handle_unmatched_requests(req, resp)
+    except Exception as error:
+        resp = BoltResponse(status=500, body="")
+        self._middleware_error_handler.handle(
+            error=error,
+            request=req,
+            response=resp,
+        )
+        return resp
+
+

Applies all middleware and dispatches an incoming request from Slack to the right code path.

+

Args

+
+
req
+
An incoming request from Slack
+
+

Returns

+

The response generated by this Bolt app

+
+
+def enable_token_revocation_listeners(self) ‑> None +
+
+
+ +Expand source code + +
def enable_token_revocation_listeners(self) -> None:
+    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+
+
+
+def error(self,
func: Callable[..., BoltResponse | None]) ‑> Callable[..., BoltResponse | None]
+
+
+
+ +Expand source code + +
def error(self, func: Callable[..., Optional[BoltResponse]]) -> Callable[..., Optional[BoltResponse]]:
+    """Updates the global error handler. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.error
+        def custom_error_handler(error, body, logger):
+            logger.exception(f"Error: {error}")
+            logger.info(f"Request body: {body}")
+
+        # Pass a function to this method
+        app.error(custom_error_handler)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        func: The function that is supposed to be executed
+            when getting an unhandled error in Bolt app.
+    """
+    self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    self._middleware_error_handler = CustomMiddlewareErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    return func
+
+

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

+
# Use this method as a decorator
+@app.error
+def custom_error_handler(error, body, logger):
+    logger.exception(f"Error: {error}")
+    logger.info(f"Request body: {body}")
+
+# Pass a function to this method
+app.error(custom_error_handler)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
func
+
The function that is supposed to be executed +when getting an unhandled error in Bolt app.
+
+
+
+def event(self,
event: str | Pattern | Dict[str, str | Sequence[str | Pattern | None] | None],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def event(
+    self,
+    event: Union[
+        str,
+        Pattern,
+        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+    ],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new event listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.event("team_join")
+        def ask_for_introduction(event, say):
+            welcome_channel_id = "C12345"
+            user_id = event["user"]
+            text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+            say(text=text, channel=welcome_channel_id)
+
+        # Pass a function to this method
+        app.event("team_join")(ask_for_introduction)
+
+    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        event: The conditions that match a request payload.
+            If you pass a dict for this, you can have type, subtype in the constraint.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    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__
+
+

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

+
# Use this method as a decorator
+@app.event("team_join")
+def ask_for_introduction(event, say):
+    welcome_channel_id = "C12345"
+    user_id = event["user"]
+    text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+    say(text=text, channel=welcome_channel_id)
+
+# Pass a function to this method
+app.event("team_join")(ask_for_introduction)
+
+

Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
event
+
The conditions that match a request payload. +If you pass a dict for this, you can have type, subtype in the constraint.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def function(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None,
auto_acknowledge: bool = True,
ack_timeout: int = 3) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def function(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    auto_acknowledge: bool = True,
+    ack_timeout: int = 3,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new Function listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.function("reverse")
+        def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+            try:
+                ack()
+                string_to_reverse = inputs["stringToReverse"]
+                complete(outputs={"reverseString": string_to_reverse[::-1]})
+            except Exception as e:
+                fail(f"Cannot reverse string (error: {e})")
+                raise e
+
+        # Pass a function to this method
+        app.function("reverse")(reverse_string)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        callback_id: The callback id to identify the function
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    if auto_acknowledge is True:
+        if ack_timeout != 3:
+            self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger)
+        return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.function("reverse")
+def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+    try:
+        ack()
+        string_to_reverse = inputs["stringToReverse"]
+        complete(outputs={"reverseString": string_to_reverse[::-1]})
+    except Exception as e:
+        fail(f"Cannot reverse string (error: {e})")
+        raise e
+
+# Pass a function to this method
+app.function("reverse")(reverse_string)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
callback_id
+
The callback id to identify the function
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def global_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def global_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new global shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.global_shortcut(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new global shortcut listener.

+
+
+def message(self,
keyword: str | Pattern = '',
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def message(
+    self,
+    keyword: Union[str, Pattern] = "",
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new message event listener. This method can be used as either a decorator or a method.
+    Check the `App#event` method's docstring for details.
+
+        # Use this method as a decorator
+        @app.message(":wave:")
+        def say_hello(message, say):
+            user = message['user']
+            say(f"Hi there, <@{user}>!")
+
+        # Pass a function to this method
+        app.message(":wave:")(say_hello)
+
+    Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        keyword: The keyword to match
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        constraints = {
+            "type": "message",
+            "subtype": (
+                # In most cases, new message events come with no subtype.
+                None,
+                # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                # By contrast, messages posted using classic app's bot token still have the subtype.
+                "bot_message",
+                # If an end-user posts a message with "Also send to #channel" checked,
+                # the message event comes with this subtype.
+                "thread_broadcast",
+                # If an end-user posts a message with attached files,
+                # the message event comes with this subtype.
+                "file_share",
+            ),
+        }
+        primary_matcher = builtin_matchers.message_event(
+            keyword=keyword, constraints=constraints, base_logger=self._base_logger
+        )
+        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)
+
+    return __call__
+
+

Registers a new message event listener. This method can be used as either a decorator or a method. +Check the App#event method's docstring for details.

+
# Use this method as a decorator
+@app.message(":wave:")
+def say_hello(message, say):
+    user = message['user']
+    say(f"Hi there, <@{user}>!")
+
+# Pass a function to this method
+app.message(":wave:")(say_hello)
+
+

Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
keyword
+
The keyword to match
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def message_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def message_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new message shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.message_shortcut(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new message shortcut listener.

+
+
+def middleware(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def middleware(self, *args) -> Optional[Callable]:
+    """Registers a new middleware to this app.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.middleware
+        def middleware_func(logger, body, next):
+            logger.info(f"request body: {body}")
+            next()
+
+        # Pass a function to this method
+        app.middleware(middleware_func)
+
+    Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        *args: A function that works as a global middleware.
+    """
+    if len(args) > 0:
+        middleware_or_callable = args[0]
+        if isinstance(middleware_or_callable, Middleware):
+            middleware: Middleware = middleware_or_callable
+            self._middleware_list.append(middleware)
+            if isinstance(middleware, Assistant) and middleware.thread_context_store is not None:
+                self._assistant_thread_context_store = middleware.thread_context_store
+        elif callable(middleware_or_callable):
+            self._middleware_list.append(
+                CustomMiddleware(
+                    app_name=self.name,
+                    func=middleware_or_callable,
+                    base_logger=self._base_logger,
+                )
+            )
+            return middleware_or_callable
+        else:
+            raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+    return None
+
+

Registers a new middleware to this app. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.middleware
+def middleware_func(logger, body, next):
+    logger.info(f"request body: {body}")
+    next()
+
+# Pass a function to this method
+app.middleware(middleware_func)
+
+

Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
*args
+
A function that works as a global middleware.
+
+
+
+def options(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def options(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new options listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.options("menu_selection")
+        def show_menu_options(ack):
+            options = [
+                {
+                    "text": {"type": "plain_text", "text": "Option 1"},
+                    "value": "1-1",
+                },
+                {
+                    "text": {"type": "plain_text", "text": "Option 2"},
+                    "value": "1-2",
+                },
+            ]
+            ack(options=options)
+
+        # Pass a function to this method
+        app.options("menu_selection")(show_menu_options)
+
+    Refer to the following documents for details:
+
+    * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+    * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.options(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.options("menu_selection")
+def show_menu_options(ack):
+    options = [
+        {
+            "text": {"type": "plain_text", "text": "Option 1"},
+            "value": "1-1",
+        },
+        {
+            "text": {"type": "plain_text", "text": "Option 2"},
+            "value": "1-2",
+        },
+    ]
+    ack(options=options)
+
+# Pass a function to this method
+app.options("menu_selection")(show_menu_options)
+
+

Refer to the following documents for details:

+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def shortcut(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def shortcut(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new shortcut listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.shortcut("open_modal")
+        def open_modal(ack, body, client):
+            # Acknowledge the command request
+            ack()
+            # Call views_open with the built-in client
+            client.views_open(
+                # Pass a valid trigger_id within 3 seconds of receiving it
+                trigger_id=body["trigger_id"],
+                # View payload
+                view={ ... }
+            )
+
+        # Pass a function to this method
+        app.shortcut("open_modal")(open_modal)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.shortcut(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.shortcut("open_modal")
+def open_modal(ack, body, client):
+    # Acknowledge the command request
+    ack()
+    # Call views_open with the built-in client
+    client.views_open(
+        # Pass a valid trigger_id within 3 seconds of receiving it
+        trigger_id=body["trigger_id"],
+        # View payload
+        view={ ... }
+    )
+
+# Pass a function to this method
+app.shortcut("open_modal")(open_modal)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def start(self,
port: int = 3000,
path: str = '/slack/events',
http_server_logger_enabled: bool = True) ‑> None
+
+
+
+ +Expand source code + +
def start(
+    self,
+    port: int = 3000,
+    path: str = "/slack/events",
+    http_server_logger_enabled: bool = True,
+) -> None:
+    """Starts a web server for local development.
+
+        # With the default settings, `http://localhost:3000/slack/events`
+        # is available for handling incoming requests from Slack
+        app.start()
+
+    This method internally starts a Web server process built with the `http.server` module.
+    For production, consider using a production-ready WSGI server such as Gunicorn.
+
+    Args:
+        port: The port to listen on (Default: 3000)
+        path: The path to handle request from Slack (Default: `/slack/events`)
+        http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
+    """
+    self._development_server = SlackAppDevelopmentServer(
+        port=port,
+        path=path,
+        app=self,
+        oauth_flow=self.oauth_flow,
+        http_server_logger_enabled=http_server_logger_enabled,
+    )
+    self._development_server.start()
+
+

Starts a web server for local development.

+
# With the default settings, `http://localhost:3000/slack/events`
+# is available for handling incoming requests from Slack
+app.start()
+
+

This method internally starts a Web server process built with the http.server module. +For production, consider using a production-ready WSGI server such as Gunicorn.

+

Args

+
+
port
+
The port to listen on (Default: 3000)
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
http_server_logger_enabled
+
The flag to enable http.server logging if True (Default: True)
+
+
+
+def step(self,
callback_id: str | Pattern | WorkflowStep | WorkflowStepBuilder,
edit: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None,
save: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None,
execute: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None)
+
+
+
+ +Expand source code + +
def step(
+    self,
+    callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
+    edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new step from app listener.
+
+    Unlike others, this method doesn't behave as a decorator.
+    If you want to register a step from app by a decorator, use `WorkflowStepBuilder`'s methods.
+
+        # Create a new WorkflowStep instance
+        from slack_bolt.workflows.step import WorkflowStep
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        # Pass Step to set up listeners
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    For further information about WorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        callback_id: The Callback ID for this step from app
+        edit: The function for displaying a modal in the Workflow Builder
+        save: The function for handling configuration in the Workflow Builder
+        execute: The function for handling the step execution
+    """
+    warnings.warn(
+        (
+            "Steps from apps for legacy workflows are now deprecated. "
+            "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+        ),
+        category=DeprecationWarning,
+    )
+    step = callback_id
+    if isinstance(callback_id, (str, Pattern)):
+        step = WorkflowStep(
+            callback_id=callback_id,
+            edit=edit,  # type: ignore[arg-type]
+            save=save,  # type: ignore[arg-type]
+            execute=execute,  # type: ignore[arg-type]
+            base_logger=self._base_logger,
+        )
+    elif isinstance(step, WorkflowStepBuilder):
+        step = step.build(base_logger=self._base_logger)
+    elif not isinstance(step, WorkflowStep):
+        raise BoltError(f"Invalid step object ({type(step)})")
+
+    self.use(WorkflowStepMiddleware(step))
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new step from app listener.

+

Unlike others, this method doesn't behave as a decorator. +If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

+
# Create a new WorkflowStep instance
+from slack_bolt.workflows.step import WorkflowStep
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+# Pass Step to set up listeners
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
callback_id
+
The Callback ID for this step from app
+
edit
+
The function for displaying a modal in the Workflow Builder
+
save
+
The function for handling configuration in the Workflow Builder
+
execute
+
The function for handling the step execution
+
+
+
+def use(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def use(self, *args) -> Optional[Callable]:
+    """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
+
+    Refer to `App#middleware()` method's docstring for details."""
+    return self.middleware(*args)
+
+

Registers a new global middleware to this app. This method can be used as either a decorator or a method.

+

Refer to App#middleware() method's docstring for details.

+
+
+def view(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_submission`/`view_closed` event listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.view("view_1")
+        def handle_submission(ack, body, client, view):
+            # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+            hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+            user = body["user"]["id"]
+            # Validate the inputs
+            errors = {}
+            if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                errors["block_c"] = "The value must be longer than 5 characters"
+            if len(errors) > 0:
+                ack(response_action="errors", errors=errors)
+                return
+            # Acknowledge the view_submission event and close the modal
+            ack()
+            # Do whatever you want with the input data - here we're saving it to a DB
+
+        # Pass a function to this method
+        app.view("view_1")(handle_submission)
+
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission/view_closed event listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.view("view_1")
+def handle_submission(ack, body, client, view):
+    # Assume there's an input block with <code>block\_c</code> as the block_id and <code>dreamy\_input</code>
+    hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+    user = body["user"]["id"]
+    # Validate the inputs
+    errors = {}
+    if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+        errors["block_c"] = "The value must be longer than 5 characters"
+    if len(errors) > 0:
+        ack(response_action="errors", errors=errors)
+        return
+    # Acknowledge the view_submission event and close the modal
+    ack()
+    # Do whatever you want with the input data - here we're saving it to a DB
+
+# Pass a function to this method
+app.view("view_1")(handle_submission)
+
+

Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def view_closed(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view_closed(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_closed` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+ +
+
+def view_submission(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view_submission(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_submission` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+    details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_submission(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/async_app.html b/docs/reference/async_app.html new file mode 100644 index 000000000..670ba58d0 --- /dev/null +++ b/docs/reference/async_app.html @@ -0,0 +1,5739 @@ + + + + + + +slack_bolt.async_app API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.async_app

+
+
+

Module for creating asyncio based apps

+

Creating an async app

+

If you'd prefer to build your app with asyncio, you can import the AIOHTTP library and call the AsyncApp constructor. Within async apps, you can use the async/await pattern.

+
# Python 3.7+ required
+python -m venv .venv
+source .venv/bin/activate
+
+pip install -U pip
+# aiohttp is required
+pip install slack_bolt aiohttp
+
+

In async apps, all middleware/listeners must be async functions. When calling utility methods (like ack and say) within these functions, it's required to use the await keyword.

+
# Import the async app instead of the regular one
+from slack_bolt.async_app import AsyncApp
+
+app = AsyncApp()
+
+@app.event("app_mention")
+async def event_test(body, say, logger):
+    logger.info(body)
+    await say("What's up?")
+
+@app.command("/hello-bolt-python")
+async def command(ack, body, respond):
+    await ack()
+    await respond(f"Hi <@{body['user_id']}>!")
+
+if __name__ == "__main__":
+    app.start(3000)
+
+

If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at the built-in adapters and their examples.

+ +

Refer to slack_bolt.app.async_app for more details.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAck +
+
+
+ +Expand source code + +
class AsyncAck:
+    response: Optional[BoltResponse]
+
+    def __init__(self):
+        self.response: Optional[BoltResponse] = None
+
+    async def __call__(
+        self,
+        text: Union[str, dict] = "",  # text: str or whole_response: dict
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        response_type: Optional[str] = None,  # in_channel / ephemeral
+        # block_suggestion / dialog_suggestion
+        options: Optional[Sequence[Union[dict, Option]]] = None,
+        option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None,
+        # view_submission
+        response_action: Optional[str] = None,  # errors / update / push / clear
+        errors: Optional[Dict[str, str]] = None,
+        view: Optional[Union[dict, View]] = None,
+    ) -> BoltResponse:
+        return _set_response(
+            self,
+            text_or_whole_response=text,
+            blocks=blocks,
+            attachments=attachments,
+            unfurl_links=unfurl_links,
+            unfurl_media=unfurl_media,
+            response_type=response_type,
+            options=options,
+            option_groups=option_groups,
+            response_action=response_action,
+            errors=errors,
+            view=view,
+        )
+
+
+

Class variables

+
+
var responseBoltResponse | None
+
+

The type of the None singleton.

+
+
+
+
+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,
attaching_conversation_kwargs_enabled: bool = True)
+
+
+
+ +Expand source code + +
class AsyncApp:
+    def __init__(
+        self,
+        *,
+        logger: Optional[logging.Logger] = None,
+        # Used in logger
+        name: Optional[str] = None,
+        # Set True when you run this app on a FaaS platform
+        process_before_response: bool = False,
+        # Set True if you want to handle an unhandled request as an exception
+        raise_error_for_unhandled_request: bool = False,
+        # Basic Information > Credentials > Signing Secret
+        signing_secret: Optional[str] = None,
+        # for single-workspace apps
+        token: Optional[str] = None,
+        client: Optional[AsyncWebClient] = None,
+        # for multi-workspace apps
+        before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None,
+        authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+        installation_store: Optional[AsyncInstallationStore] = None,
+        # for either only bot scope usage or v1.0.x compatibility
+        installation_store_bot_only: Optional[bool] = None,
+        # for customizing the built-in middleware
+        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,
+        # for the OAuth flow
+        oauth_settings: Optional[AsyncOAuthSettings] = None,
+        oauth_flow: Optional[AsyncOAuthFlow] = None,
+        # No need to set (the value is used only in response to ssl_check requests)
+        verification_token: Optional[str] = None,
+        # 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.
+
+            import os
+            from slack_bolt.async_app import AsyncApp
+
+            # Initializes your app with your bot token and signing secret
+            app = AsyncApp(
+                token=os.environ.get("SLACK_BOT_TOKEN"),
+                signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+            )
+
+            # Listens to incoming messages that contain "hello"
+            @app.message("hello")
+            async def message_hello(message, say):  # async function
+                # say() sends a message to the channel where the event was triggered
+                await say(f"Hey there <@{message['user']}>!")
+
+            # Start your app
+            if __name__ == "__main__":
+                app.start(port=int(os.environ.get("PORT", 3000)))
+
+        Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details.
+
+        If you would like to build an OAuth app for enabling the app to run with multiple workspaces,
+        refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app.
+
+        Args:
+            logger: The custom logger that can be used in this app.
+            name: The application name that will be used in logging. If absent, the source file name will be used.
+            process_before_response: True if this app runs on Function as a Service. (Default: False)
+            raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
+                and use @app.error listeners instead of
+                the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+            signing_secret: The Signing Secret value used for verifying requests from Slack.
+            token: The bot/user access token required only for single-workspace app.
+            client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app.
+            before_authorize: A global middleware that can be executed right before authorize function
+            authorize: The function to authorize an incoming request from Slack
+                by checking if there is a team/user in the installation data.
+            user_facing_authorize_error_message: The user-facing error message to display
+                when the app is installed but the installation is not managed by this app's installation store
+            installation_store: The module offering save/find operations of installation data
+            installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False)
+            request_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncRequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests.
+                Make sure if it's safe enough when you turn a built-in middleware off.
+                We strongly recommend using RequestVerification for better security.
+                If you have a proxy that verifies request signature in front of the Bolt app,
+                it's totally fine to disable RequestVerification to avoid duplication of work.
+                Don't turn it off just for easiness of development.
+            ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events
+                generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+            ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware.
+                `IgnoringSelfEvents` for this app's bot user message events within an assistant thread
+                This is useful for avoiding code error causing an infinite loop; Default: True
+            url_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncUrlVerification` is a built-in middleware that handles url_verification requests
+                that verify the endpoint for Events API in HTTP Mode requests.
+            ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True).
+                `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack.
+            attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AsyncAttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution token
+                when your app receives `function_executed` or interactivity events scoped to a custom step.
+            oauth_settings: The settings related to Slack app installation flow (OAuth flow)
+            oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings.
+            verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests.
+            assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation,
+                which uses a parent message's metadata to store the latest context)
+        """
+        if signing_secret is None:
+            signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "")
+        token = token or os.environ.get("SLACK_BOT_TOKEN")
+
+        self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1]
+        self._signing_secret: str = signing_secret
+        self._verification_token: Optional[str] = verification_token or os.environ.get("SLACK_VERIFICATION_TOKEN", None)
+        # If a logger is explicitly passed when initializing, the logger works as the base logger.
+        # The base logger's logging settings will be propagated to all the loggers created by bolt-python.
+        self._base_logger = logger
+        # The framework logger is supposed to be used for the internal logging.
+        # Also, it's accessible via `app.logger` as the app's singleton logger.
+        self._framework_logger = logger or get_bolt_logger(AsyncApp)
+        self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
+
+        self._token: Optional[str] = token
+
+        if client is not None:
+            if not isinstance(client, AsyncWebClient):
+                raise BoltError(error_client_invalid_type_async())
+            self._async_client = client
+            self._token = client.token
+            if token is not None:
+                self._framework_logger.warning(warning_client_prioritized_and_token_skipped())
+        else:
+            self._async_client = create_async_web_client(
+                # NOTE: the token here can be None
+                token=token,
+                logger=self._framework_logger,
+            )
+
+        # --------------------------------------
+        # Authorize & OAuthFlow initialization
+        # --------------------------------------
+
+        self._async_before_authorize: Optional[AsyncMiddleware] = None
+        if before_authorize is not None:
+            if callable(before_authorize):
+                self._async_before_authorize = AsyncCustomMiddleware(
+                    app_name=self._name,
+                    func=before_authorize,
+                    base_logger=self._framework_logger,
+                )
+            elif isinstance(before_authorize, AsyncMiddleware):
+                self._async_before_authorize = before_authorize
+
+        self._async_authorize: Optional[AsyncAuthorize] = None
+        if authorize is not None:
+            if isinstance(authorize, AsyncAuthorize):
+                # As long as an advanced developer understands what they're doing,
+                # bolt-python should not prevent customizing authorize middleware
+                self._async_authorize = authorize
+            else:
+                if oauth_settings is not None or oauth_flow is not None:
+                    # If the given authorize is a simple function,
+                    # it does not work along with installation_store.
+                    raise BoltError(error_authorize_conflicts())
+                self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize)
+
+        self._async_installation_store: Optional[AsyncInstallationStore] = installation_store
+        if self._async_installation_store is not None and self._async_authorize is None:
+            settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
+            self._async_authorize = AsyncInstallationStoreAuthorize(
+                installation_store=self._async_installation_store,
+                client_id=settings.client_id if settings is not None else None,
+                client_secret=settings.client_secret if settings is not None else None,
+                logger=self._framework_logger,
+                bot_only=installation_store_bot_only or False,
+                client=self._async_client,  # for proxy use cases etc.
+                user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"),
+            )
+
+        self._async_oauth_flow: Optional[AsyncOAuthFlow] = None
+
+        if (
+            oauth_settings is None
+            and os.environ.get("SLACK_CLIENT_ID") is not None
+            and os.environ.get("SLACK_CLIENT_SECRET") is not None
+        ):
+            # initialize with the default settings
+            oauth_settings = AsyncOAuthSettings()
+
+            if oauth_flow is None and installation_store is None:
+                # show info-level log for avoiding confusions
+                self._framework_logger.info(info_default_oauth_settings_loaded())
+
+        if oauth_flow:
+            if not isinstance(oauth_flow, AsyncOAuthFlow):
+                raise BoltError(error_oauth_flow_invalid_type_async())
+
+            self._async_oauth_flow = oauth_flow
+            installation_store = select_consistent_installation_store(
+                client_id=self._async_oauth_flow.client_id,
+                app_store=self._async_installation_store,
+                oauth_flow_store=self._async_oauth_flow.settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._async_installation_store = installation_store
+            if installation_store is not None:
+                self._async_oauth_flow.settings.installation_store = installation_store
+
+            if self._async_oauth_flow._async_client is None:
+                self._async_oauth_flow._async_client = self._async_client
+            if self._async_authorize is None:
+                self._async_authorize = self._async_oauth_flow.settings.authorize
+        elif oauth_settings is not None:
+            if not isinstance(oauth_settings, AsyncOAuthSettings):
+                raise BoltError(error_oauth_settings_invalid_type_async())
+
+            installation_store = select_consistent_installation_store(
+                client_id=oauth_settings.client_id,
+                app_store=self._async_installation_store,
+                oauth_flow_store=oauth_settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._async_installation_store = installation_store
+            if installation_store is not None:
+                oauth_settings.installation_store = installation_store
+
+            self._async_oauth_flow = AsyncOAuthFlow(client=self._async_client, logger=self.logger, settings=oauth_settings)
+            if self._async_authorize is None:
+                self._async_authorize = self._async_oauth_flow.settings.authorize
+            self._async_authorize.token_rotation_expiration_minutes = oauth_settings.token_rotation_expiration_minutes  # type: ignore[attr-defined] # noqa: E501
+
+        if (self._async_installation_store is not None or self._async_authorize is not None) and self._token is not None:
+            self._token = None
+            self._framework_logger.warning(warning_token_skipped())
+
+        # after setting bot_only here, __init__ cannot replace authorize function
+        if installation_store_bot_only is not None and self._async_oauth_flow is not None:
+            app_bot_only = installation_store_bot_only or False
+            oauth_flow_bot_only = self._async_oauth_flow.settings.installation_store_bot_only
+            if app_bot_only != oauth_flow_bot_only:
+                self.logger.warning(warning_bot_only_conflicts())
+                self._async_oauth_flow.settings.installation_store_bot_only = app_bot_only
+                self._async_authorize.bot_only = app_bot_only  # type: ignore[union-attr]
+
+        self._async_tokens_revocation_listeners: Optional[AsyncTokenRevocationListeners] = None
+        if self._async_installation_store is not None:
+            self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners(self._async_installation_store)
+
+        # --------------------------------------
+        # Middleware Initialization
+        # --------------------------------------
+
+        self._async_middleware_list: List[AsyncMiddleware] = []
+        self._async_listeners: List[AsyncListener] = []
+
+        self._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(
+            logger=self._framework_logger,
+            process_before_response=process_before_response,
+            listener_error_handler=AsyncDefaultListenerErrorHandler(logger=self._framework_logger),
+            listener_start_handler=AsyncDefaultListenerStartHandler(logger=self._framework_logger),
+            listener_completion_handler=AsyncDefaultListenerCompletionHandler(logger=self._framework_logger),
+            lazy_listener_runner=AsyncioLazyListenerRunner(
+                logger=self._framework_logger,
+            ),
+        )
+        self._async_middleware_error_handler: AsyncMiddlewareErrorHandler = AsyncDefaultMiddlewareErrorHandler(
+            logger=self._framework_logger,
+        )
+
+        self._init_middleware_list_done = False
+        self._init_async_middleware_list(
+            request_verification_enabled=request_verification_enabled,
+            ignoring_self_events_enabled=ignoring_self_events_enabled,
+            ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+            ssl_check_enabled=ssl_check_enabled,
+            url_verification_enabled=url_verification_enabled,
+            attaching_function_token_enabled=attaching_function_token_enabled,
+            user_facing_authorize_error_message=user_facing_authorize_error_message,
+        )
+
+        self._server: Optional[AsyncSlackAppServer] = None
+
+    def _init_async_middleware_list(
+        self,
+        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,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        if self._init_middleware_list_done:
+            return
+        if ssl_check_enabled is True:
+            self._async_middleware_list.append(
+                AsyncSslCheck(
+                    verification_token=self._verification_token,
+                    base_logger=self._base_logger,
+                )
+            )
+        if request_verification_enabled is True:
+            self._async_middleware_list.append(AsyncRequestVerification(self._signing_secret, base_logger=self._base_logger))
+
+        if self._async_before_authorize is not None:
+            self._async_middleware_list.append(self._async_before_authorize)
+
+        # As authorize is required for making a Bolt app function, we don't offer the flag to disable this
+        if self._async_oauth_flow is None:
+            if self._token:
+                self._async_middleware_list.append(
+                    AsyncSingleTeamAuthorization(
+                        base_logger=self._base_logger,
+                        user_facing_authorize_error_message=user_facing_authorize_error_message,
+                    )
+                )
+            elif self._async_authorize is not None:
+                self._async_middleware_list.append(
+                    AsyncMultiTeamsAuthorization(
+                        authorize=self._async_authorize,
+                        base_logger=self._base_logger,
+                        user_facing_authorize_error_message=user_facing_authorize_error_message,
+                    )
+                )
+            else:
+                raise BoltError(error_token_required())
+        elif self._async_authorize is not None:
+            self._async_middleware_list.append(
+                AsyncMultiTeamsAuthorization(
+                    authorize=self._async_authorize,
+                    base_logger=self._base_logger,
+                    user_token_resolution=self._async_oauth_flow.settings.user_token_resolution,
+                    user_facing_authorize_error_message=user_facing_authorize_error_message,
+                )
+            )
+        else:
+            raise BoltError(error_oauth_flow_or_authorize_required())
+
+        if ignoring_self_events_enabled is True:
+            self._async_middleware_list.append(
+                AsyncIgnoringSelfEvents(
+                    base_logger=self._base_logger,
+                    ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+                )
+            )
+        if url_verification_enabled is True:
+            self._async_middleware_list.append(AsyncUrlVerification(base_logger=self._base_logger))
+        if attaching_function_token_enabled is True:
+            self._async_middleware_list.append(AsyncAttachingFunctionToken())
+        self._init_middleware_list_done = True
+
+    # -------------------------
+    # accessors
+
+    @property
+    def name(self) -> str:
+        """The name of this app (default: the filename)"""
+        return self._name
+
+    @property
+    def oauth_flow(self) -> Optional[AsyncOAuthFlow]:
+        """Configured `OAuthFlow` object if exists."""
+        return self._async_oauth_flow
+
+    @property
+    def client(self) -> AsyncWebClient:
+        """The singleton `slack_sdk.web.async_client.AsyncWebClient` instance in this app."""
+        return self._async_client
+
+    @property
+    def logger(self) -> logging.Logger:
+        """The logger this app uses."""
+        return self._framework_logger
+
+    @property
+    def installation_store(self) -> Optional[AsyncInstallationStore]:
+        """The `slack_sdk.oauth.AsyncInstallationStore` that can be used in the `authorize` middleware."""
+        return self._async_installation_store
+
+    @property
+    def listener_runner(self) -> AsyncioListenerRunner:
+        """The asyncio-based executor for asynchronously running listeners."""
+        return self._async_listener_runner
+
+    @property
+    def process_before_response(self) -> bool:
+        return self._process_before_response or False
+
+    # -------------------------
+    # standalone server
+
+    from .async_server import AsyncSlackAppServer
+
+    def server(
+        self,
+        port: int = 3000,
+        path: str = "/slack/events",
+        host: Optional[str] = None,
+    ) -> AsyncSlackAppServer:
+        """Configure a web server using AIOHTTP.
+        Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+        Args:
+            port: The port to listen on (Default: 3000)
+            path: The path to handle request from Slack (Default: `/slack/events`)
+            host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+        """
+        if self._server is None or self._server.port != port or self._server.path != path:
+            self._server = AsyncSlackAppServer(
+                port=port,
+                path=path,
+                app=self,
+                host=host,
+            )
+        return self._server
+
+    def web_app(self, path: str = "/slack/events", port: int = 3000) -> web.Application:
+        """Returns a `web.Application` instance for aiohttp-devtools users.
+
+            from slack_bolt.async_app import AsyncApp
+            app = AsyncApp()
+
+            @app.event("app_mention")
+            async def event_test(body, say, logger):
+                logger.info(body)
+                await say("What's up?")
+
+            def app_factory():
+                return app.web_app()
+
+            # adev runserver --port 3000 --app-factory app_factory async_app.py
+
+        Args:
+            path: The path to receive incoming requests from Slack
+            port: The port to listen on (Default: 3000)
+        """
+        return self.server(path=path, port=port).web_app
+
+    def start(self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None) -> None:
+        """Start a web server using AIOHTTP.
+        Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+        Args:
+            port: The port to listen on (Default: 3000)
+            path: The path to handle request from Slack (Default: `/slack/events`)
+            host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+        """
+        self.server(port=port, path=path, host=host).start()
+
+    # -------------------------
+    # main dispatcher
+
+    async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse:
+        """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+        Args:
+            req: An incoming request from Slack.
+
+        Returns:
+            The response generated by this Bolt app.
+        """
+        starting_time = time.time()
+        self._init_context(req)
+
+        resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+        middleware_state = {"next_called": False}
+
+        async def async_middleware_next():
+            middleware_state["next_called"] = True
+
+        try:
+            for middleware in self._async_middleware_list:
+                middleware_state["next_called"] = False
+                if self._framework_logger.level <= logging.DEBUG:
+                    self._framework_logger.debug(f"Applying {middleware.name}")
+                resp = await middleware.async_process(
+                    req=req, resp=resp, next=async_middleware_next  # type: ignore[arg-type]
+                )
+                if not middleware_state["next_called"]:
+                    if resp is None:
+                        # next() method was not called without providing the response to return to Slack
+                        # This should not be an intentional handling in usual use cases.
+                        resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                        if self._raise_error_for_unhandled_request is True:
+                            try:
+                                raise BoltUnhandledRequestError(
+                                    request=req,
+                                    current_response=resp,
+                                    last_global_middleware_name=middleware.name,
+                                )
+                            except BoltUnhandledRequestError as e:
+                                await self._async_listener_runner.listener_error_handler.handle(
+                                    error=e,
+                                    request=req,
+                                    response=resp,
+                                )
+                            return resp
+                        self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                        return resp
+                    return resp
+
+            for listener in self._async_listeners:
+                listener_name = get_name_for_callable(listener.ack_function)
+                self._framework_logger.debug(debug_checking_listener(listener_name))
+                if await listener.async_matches(req=req, resp=resp):  # type: ignore[arg-type]
+                    # run all the middleware attached to this listener first
+                    middleware_resp, next_was_not_called = await listener.run_async_middleware(
+                        req=req, resp=resp  # type: ignore[arg-type]
+                    )
+                    if next_was_not_called:
+                        if middleware_resp is not None:
+                            if self._framework_logger.level <= logging.DEBUG:
+                                debug_message = debug_return_listener_middleware_response(
+                                    listener_name,
+                                    middleware_resp.status,
+                                    middleware_resp.body,
+                                    starting_time,
+                                )
+                                self._framework_logger.debug(debug_message)
+                            return middleware_resp
+                        # The last listener middleware didn't call next() method.
+                        # This means the listener is not for this incoming request.
+                        continue
+
+                    if middleware_resp is not None:
+                        resp = middleware_resp
+
+                    self._framework_logger.debug(debug_running_listener(listener_name))
+                    listener_response: Optional[BoltResponse] = await self._async_listener_runner.run(
+                        request=req,
+                        response=resp,  # type: ignore[arg-type]
+                        listener_name=listener_name,
+                        listener=listener,
+                    )
+                    if listener_response is not None:
+                        return listener_response
+
+            if resp is None:
+                resp = BoltResponse(status=404, body={"error": "unhandled request"})
+            if self._raise_error_for_unhandled_request is True:
+                try:
+                    raise BoltUnhandledRequestError(
+                        request=req,
+                        current_response=resp,
+                    )
+                except BoltUnhandledRequestError as e:
+                    await self._async_listener_runner.listener_error_handler.handle(
+                        error=e,
+                        request=req,
+                        response=resp,
+                    )
+                return resp
+            return self._handle_unmatched_requests(req, resp)
+
+        except Exception as error:
+            resp = BoltResponse(status=500, body="")
+            await self._async_middleware_error_handler.handle(
+                error=error,
+                request=req,
+                response=resp,
+            )
+            return resp
+
+    def _handle_unmatched_requests(self, req: AsyncBoltRequest, resp: BoltResponse) -> BoltResponse:
+        self._framework_logger.warning(warning_unhandled_request(req))
+        return resp
+
+    # -------------------------
+    # middleware
+
+    def use(self, *args) -> Optional[Callable]:
+        """Refer to `AsyncApp#middleware()` method's docstring for details."""
+        return self.middleware(*args)
+
+    def middleware(self, *args) -> Optional[Callable]:
+        """Registers a new middleware to this app.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.middleware
+            async def middleware_func(logger, body, next):
+                logger.info(f"request body: {body}")
+                await next()
+
+            # Pass a function to this method
+            app.middleware(middleware_func)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            *args: A function that works as a global middleware.
+        """
+        if len(args) > 0:
+            middleware_or_callable = args[0]
+            if isinstance(middleware_or_callable, AsyncMiddleware):
+                middleware: AsyncMiddleware = middleware_or_callable
+                self._async_middleware_list.append(middleware)
+                if isinstance(middleware, AsyncAssistant) and middleware.thread_context_store is not None:
+                    self._assistant_thread_context_store = middleware.thread_context_store
+            elif callable(middleware_or_callable):
+                self._async_middleware_list.append(
+                    AsyncCustomMiddleware(
+                        app_name=self.name,
+                        func=middleware_or_callable,
+                        base_logger=self._base_logger,
+                    )
+                )
+                return middleware_or_callable
+            else:
+                raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+        return None
+
+    def assistant(self, assistant: AsyncAssistant) -> Optional[Callable]:
+        return self.middleware(assistant)
+
+    # -------------------------
+    # Workflows: Steps from apps
+
+    def step(
+        self,
+        callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder],
+        edit: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+        save: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+        execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new step from app listener.
+
+        Unlike others, this method doesn't behave as a decorator.
+        If you want to register a step from app by a decorator, use `AsyncWorkflowStepBuilder`'s methods.
+
+            # Create a new WorkflowStep instance
+            from slack_bolt.workflows.async_step import AsyncWorkflowStep
+            ws = AsyncWorkflowStep(
+                callback_id="add_task",
+                edit=edit,
+                save=save,
+                execute=execute,
+            )
+            # Pass Step to set up listeners
+            app.step(ws)
+
+        Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+        For further information about AsyncWorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            callback_id: The Callback ID for this step from app
+            edit: The function for displaying a modal in the Workflow Builder
+            save: The function for handling configuration in the Workflow Builder
+            execute: The function for handling the step execution
+        """
+        warnings.warn(
+            (
+                "Steps from apps for legacy workflows are now deprecated. "
+                "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+            ),
+            category=DeprecationWarning,
+        )
+        step = callback_id
+        if isinstance(callback_id, (str, Pattern)):
+            step = AsyncWorkflowStep(
+                callback_id=callback_id,
+                edit=edit,  # type: ignore[arg-type]
+                save=save,  # type: ignore[arg-type]
+                execute=execute,  # type: ignore[arg-type]
+                base_logger=self._base_logger,
+            )
+        elif isinstance(step, AsyncWorkflowStepBuilder):
+            step = step.build(base_logger=self._base_logger)
+        elif not isinstance(step, AsyncWorkflowStep):
+            raise BoltError(f"Invalid step object ({type(step)})")
+
+        self.use(AsyncWorkflowStepMiddleware(step))
+
+    # -------------------------
+    # global error handler
+
+    def error(
+        self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
+    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+        """Updates the global error handler. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.error
+            async def custom_error_handler(error, body, logger):
+                logger.exception(f"Error: {error}")
+                logger.info(f"Request body: {body}")
+
+            # Pass a function to this method
+            app.error(custom_error_handler)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            func: The function that is supposed to be executed
+                when getting an unhandled error in Bolt app.
+        """
+        if not is_callable_coroutine(func):
+            name = get_name_for_callable(func)
+            raise BoltError(error_listener_function_must_be_coro_func(name))
+        self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        return func
+
+    # -------------------------
+    # events
+
+    def event(
+        self,
+        event: Union[
+            str,
+            Pattern,
+            Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+        ],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new event listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.event("team_join")
+            async def ask_for_introduction(event, say):
+                welcome_channel_id = "C12345"
+                user_id = event["user"]
+                text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+                await say(text=text, channel=welcome_channel_id)
+
+            # Pass a function to this method
+            app.event("team_join")(ask_for_introduction)
+
+        Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            event: The conditions that match a request payload.
+                If you pass a dict for this, you can have type, subtype in the constraint.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        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__
+
+    def message(
+        self,
+        keyword: Union[str, Pattern] = "",
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new message event listener. This method can be used as either a decorator or a method.
+        Check the `App#event` method's docstring for details.
+
+            # Use this method as a decorator
+            @app.message(":wave:")
+            async def say_hello(message, say):
+                user = message['user']
+                await say(f"Hi there, <@{user}>!")
+
+            # Pass a function to this method
+            app.message(":wave:")(say_hello)
+
+        Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            keyword: The keyword to match
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            constraints = {
+                "type": "message",
+                "subtype": (
+                    # In most cases, new message events come with no subtype.
+                    None,
+                    # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                    # By contrast, messages posted using classic app's bot token still have the subtype.
+                    "bot_message",
+                    # If an end-user posts a message with "Also send to #channel" checked,
+                    # the message event comes with this subtype.
+                    "thread_broadcast",
+                    # If an end-user posts a message with attached files,
+                    # the message event comes with this subtype.
+                    "file_share",
+                ),
+            }
+            primary_matcher = builtin_matchers.message_event(
+                constraints=constraints,
+                keyword=keyword,
+                asyncio=True,
+                base_logger=self._base_logger,
+            )
+            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)
+
+        return __call__
+
+    def function(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+        auto_acknowledge: bool = True,
+        ack_timeout: int = 3,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]:
+        """Registers a new Function listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.function("reverse")
+            async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail):
+                try:
+                    await ack()
+                    string_to_reverse = inputs["stringToReverse"]
+                    await complete({"reverseString": string_to_reverse[::-1]})
+                except Exception as e:
+                    await fail(f"Cannot reverse string (error: {e})")
+                    raise e
+
+            # Pass a function to this method
+            app.function("reverse")(reverse_string)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            callback_id: The callback id to identify the function
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        if auto_acknowledge is True:
+            if ack_timeout != 3:
+                self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.function_executed(
+                callback_id=callback_id, base_logger=self._base_logger, asyncio=True
+            )
+            return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+        return __call__
+
+    # -------------------------
+    # slash commands
+
+    def command(
+        self,
+        command: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new slash command listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.command("/echo")
+            async def repeat_text(ack, say, command):
+                # Acknowledge command request
+                await ack()
+                await say(f"{command['text']}")
+
+            # Pass a function to this method
+            app.command("/echo")(repeat_text)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            command: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.command(command, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # shortcut
+
+    def shortcut(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new shortcut listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.shortcut("open_modal")
+            async def open_modal(ack, body, client):
+                # Acknowledge the command request
+                await ack()
+                # Call views_open with the built-in client
+                await client.views_open(
+                    # Pass a valid trigger_id within 3 seconds of receiving it
+                    trigger_id=body["trigger_id"],
+                    # View payload
+                    view={ ... }
+                )
+
+            # Pass a function to this method
+            app.shortcut("open_modal")(open_modal)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.shortcut(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def global_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new global shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.global_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def message_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new message shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.message_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # action
+
+    def action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new action listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.action("approve_button")
+            async def update_message(ack):
+                await ack()
+
+            # Pass a function to this method
+            app.action("approve_button")(update_message)
+
+        * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+        * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+        * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.action(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `block_actions` action listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_action(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def attachment_action(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `interactive_message` action listener.
+        Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.attachment_action(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_submission(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `dialog_submission` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_submission(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_cancellation(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `dialog_submission` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_cancellation(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # view
+
+    def view(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `view_submission`/`view_closed` event listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.view("view_1")
+            async def handle_submission(ack, body, client, view):
+                # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+                hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+                user = body["user"]["id"]
+                # Validate the inputs
+                errors = {}
+                if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                    errors["block_c"] = "The value must be longer than 5 characters"
+                if len(errors) > 0:
+                    await ack(response_action="errors", errors=errors)
+                    return
+                # Acknowledge the view_submission event and close the modal
+                await ack()
+                # Do whatever you want with the input data - here we're saving it to a DB
+
+            # Pass a function to this method
+            app.view("view_1")(handle_submission)
+
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_submission(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `view_submission` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+        details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_submission(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_closed(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `view_closed` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_closed(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # options
+
+    def options(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new options listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.options("menu_selection")
+            async def show_menu_options(ack):
+                options = [
+                    {
+                        "text": {"type": "plain_text", "text": "Option 1"},
+                        "value": "1-1",
+                    },
+                    {
+                        "text": {"type": "plain_text", "text": "Option 2"},
+                        "value": "1-2",
+                    },
+                ]
+                await ack(options=options)
+
+            # Pass a function to this method
+            app.options("menu_selection")(show_menu_options)
+
+        Refer to the following documents for details:
+
+        * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+        * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+        Args:
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.options(constraints, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_suggestion(
+        self,
+        action_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `block_suggestion` listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_suggestion(action_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_suggestion(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        """Registers a new `dialog_suggestion` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_suggestion(callback_id, asyncio=True, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # built-in listener functions
+
+    def default_tokens_revoked_event_listener(
+        self,
+    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+        if self._async_tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._async_tokens_revocation_listeners.handle_tokens_revoked_events
+
+    def default_app_uninstalled_event_listener(
+        self,
+    ) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+        if self._async_tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._async_tokens_revocation_listeners.handle_app_uninstalled_events
+
+    def enable_token_revocation_listeners(self) -> None:
+        self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+        self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+    # -------------------------
+
+    def _init_context(self, req: AsyncBoltRequest):
+        req.context["logger"] = get_bolt_app_logger(app_name=self.name, base_logger=self._base_logger)
+        req.context["token"] = self._token
+        # Prior to version 1.15, when the token is static, self._client was passed to `req.context`.
+        # The intention was to avoid creating a new instance per request
+        # in the interest of runtime performance/memory footprint optimization.
+        # However, developers may want to replace the token held by req.context.client in some situations.
+        # In this case, this behavior can result in thread-unsafe data modification on `self._client`.
+        # (`self._client` a.k.a. `app.client` is a singleton object per an App instance)
+        # Thus, we've changed the behavior to create a new instance per request regardless of token argument
+        # in the App initialization starting v1.15.
+        # The overhead brought by this change is slight so that we believe that it is ignorable in any cases.
+        client_per_request: AsyncWebClient = AsyncWebClient(
+            token=self._token,  # this can be None, and it can be set later on
+            base_url=self._async_client.base_url,
+            timeout=self._async_client.timeout,
+            ssl=self._async_client.ssl,
+            proxy=self._async_client.proxy,
+            session=self._async_client.session,
+            trust_env_in_session=self._async_client.trust_env_in_session,
+            headers=self._async_client.headers,
+            team_id=req.context.team_id,
+            logger=self._async_client.logger,
+            retry_handlers=(
+                self._async_client.retry_handlers.copy() if self._async_client.retry_handlers is not None else None
+            ),
+        )
+        req.context["client"] = client_per_request
+
+        # Most apps do not need this "listener_runner" instance.
+        # It is intended for apps that start lazy listeners from their custom global middleware.
+        req.context["listener_runner"] = self.listener_runner
+
+    @staticmethod
+    def _to_listener_functions(
+        kwargs: dict,
+    ) -> Optional[Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+        if kwargs:
+            functions = [kwargs["ack"]]
+            for sub in kwargs["lazy"]:
+                functions.append(sub)
+            return functions
+        return None
+
+    def _register_listener(
+        self,
+        functions: Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]],
+        primary_matcher: AsyncListenerMatcher,
+        matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]],
+        middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+    ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]:
+        value_to_return = None
+        if not isinstance(functions, list):
+            functions = list(functions)
+        if len(functions) == 1:
+            # In the case where the function is registered using decorator,
+            # the registration should return the original function.
+            value_to_return = functions[0]
+
+        for func in functions:
+            if not is_callable_coroutine(func):
+                name = get_name_for_callable(func)
+                raise BoltError(error_listener_function_must_be_coro_func(name))
+
+        listener_matchers: List[AsyncListenerMatcher] = [
+            AsyncCustomListenerMatcher(app_name=self.name, func=f, base_logger=self._base_logger) for f in (matchers or [])
+        ]
+        listener_matchers.insert(0, primary_matcher)
+        listener_middleware = []
+        for m in middleware or []:
+            if isinstance(m, AsyncMiddleware):
+                listener_middleware.append(m)
+            elif callable(m) and is_callable_coroutine(m):
+                listener_middleware.append(AsyncCustomMiddleware(app_name=self.name, func=m, base_logger=self._base_logger))
+            else:
+                raise ValueError(error_unexpected_listener_middleware(type(m)))
+
+        self._async_listeners.append(
+            AsyncCustomListener(
+                app_name=self.name,
+                ack_function=functions.pop(0),
+                lazy_functions=functions,  # type: ignore[arg-type]
+                matchers=listener_matchers,
+                middleware=listener_middleware,
+                auto_acknowledgement=auto_acknowledgement,
+                ack_timeout=ack_timeout,
+                base_logger=self._base_logger,
+            )
+        )
+
+        return value_to_return
+
+

Bolt App that provides functionalities to register middleware/listeners.

+
import os
+from slack_bolt.async_app import AsyncApp
+
+# Initializes your app with your bot token and signing secret
+app = AsyncApp(
+    token=os.environ.get("SLACK_BOT_TOKEN"),
+    signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+)
+
+# Listens to incoming messages that contain "hello"
+@app.message("hello")
+async def message_hello(message, say):  # async function
+    # say() sends a message to the channel where the event was triggered
+    await say(f"Hey there <@{message['user']}>!")
+
+# Start your app
+if __name__ == "__main__":
+    app.start(port=int(os.environ.get("PORT", 3000)))
+
+

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

+

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

+

Args

+
+
logger
+
The custom logger that can be used in this app.
+
name
+
The application name that will be used in logging. If absent, the source file name will be used.
+
process_before_response
+
True if this app runs on Function as a Service. (Default: False)
+
raise_error_for_unhandled_request
+
True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+
signing_secret
+
The Signing Secret value used for verifying requests from Slack.
+
token
+
The bot/user access token required only for single-workspace app.
+
client
+
The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app.
+
before_authorize
+
A global middleware that can be executed right before authorize function
+
authorize
+
The function to authorize an incoming request from Slack +by checking if there is a team/user in the installation data.
+
user_facing_authorize_error_message
+
The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
+
installation_store
+
The module offering save/find operations of installation data
+
installation_store_bot_only
+
Use AsyncInstallationStore#async_find_bot() if True (Default: False)
+
request_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncRequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
+
ignoring_self_events_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncIgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+
ignoring_self_assistant_message_events_enabled
+
False if you would like to disable the built-in middleware. +IgnoringSelfEvents for this app's bot user message events within an assistant thread +This is useful for avoiding code error causing an infinite loop; Default: True
+
url_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncUrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
+
ssl_check_enabled
+
bool = False if you would like to disable the built-in middleware (Default: True). +AsyncSslCheck is a built-in middleware that handles ssl_check requests from Slack.
+
attaching_function_token_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AsyncAttachingFunctionToken is a built-in middleware that injects the just-in-time workflow-execution token +when your app receives function_executed or interactivity events scoped to a custom step.
+
oauth_settings
+
The settings related to Slack app installation flow (OAuth flow)
+
oauth_flow
+
Instantiated slack_bolt.oauth.AsyncOAuthFlow. This is always prioritized over oauth_settings.
+
verification_token
+
Deprecated verification mechanism. This can be used only for ssl_check requests.
+
assistant_thread_context_store
+
Custom AssistantThreadContext store (Default: the built-in implementation, +which uses a parent message's metadata to store the latest context)
+
+

Class variables

+
+
var AsyncSlackAppServer
+
+

The type of the None singleton.

+
+
+

Instance variables

+
+
prop client : slack_sdk.web.async_client.AsyncWebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> AsyncWebClient:
+    """The singleton `slack_sdk.web.async_client.AsyncWebClient` instance in this app."""
+    return self._async_client
+
+

The singleton slack_sdk.web.async_client.AsyncWebClient instance in this app.

+
+
prop installation_store : slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None
+
+
+ +Expand source code + +
@property
+def installation_store(self) -> Optional[AsyncInstallationStore]:
+    """The `slack_sdk.oauth.AsyncInstallationStore` that can be used in the `authorize` middleware."""
+    return self._async_installation_store
+
+

The slack_sdk.oauth.AsyncInstallationStore that can be used in the authorize middleware.

+
+
prop listener_runnerAsyncioListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> AsyncioListenerRunner:
+    """The asyncio-based executor for asynchronously running listeners."""
+    return self._async_listener_runner
+
+

The asyncio-based executor for asynchronously running listeners.

+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> logging.Logger:
+    """The logger this app uses."""
+    return self._framework_logger
+
+

The logger this app uses.

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this app (default: the filename)"""
+    return self._name
+
+

The name of this app (default: the filename)

+
+
prop oauth_flowAsyncOAuthFlow | None
+
+
+ +Expand source code + +
@property
+def oauth_flow(self) -> Optional[AsyncOAuthFlow]:
+    """Configured `OAuthFlow` object if exists."""
+    return self._async_oauth_flow
+
+

Configured OAuthFlow object if exists.

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

Methods

+
+
+def action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new action listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.action("approve_button")
+        async def update_message(ack):
+            await ack()
+
+        # Pass a function to this method
+        app.action("approve_button")(update_message)
+
+    * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+    * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+    * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.action(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.action("approve_button")
+async def update_message(ack):
+    await ack()
+
+# Pass a function to this method
+app.action("approve_button")(update_message)
+
+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def assistant(self,
assistant: AsyncAssistant) ‑> Callable | None
+
+
+
+ +Expand source code + +
def assistant(self, assistant: AsyncAssistant) -> Optional[Callable]:
+    return self.middleware(assistant)
+
+
+
+
+async def async_dispatch(self,
req: AsyncBoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse:
+    """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+    Args:
+        req: An incoming request from Slack.
+
+    Returns:
+        The response generated by this Bolt app.
+    """
+    starting_time = time.time()
+    self._init_context(req)
+
+    resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+    middleware_state = {"next_called": False}
+
+    async def async_middleware_next():
+        middleware_state["next_called"] = True
+
+    try:
+        for middleware in self._async_middleware_list:
+            middleware_state["next_called"] = False
+            if self._framework_logger.level <= logging.DEBUG:
+                self._framework_logger.debug(f"Applying {middleware.name}")
+            resp = await middleware.async_process(
+                req=req, resp=resp, next=async_middleware_next  # type: ignore[arg-type]
+            )
+            if not middleware_state["next_called"]:
+                if resp is None:
+                    # next() method was not called without providing the response to return to Slack
+                    # This should not be an intentional handling in usual use cases.
+                    resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                    if self._raise_error_for_unhandled_request is True:
+                        try:
+                            raise BoltUnhandledRequestError(
+                                request=req,
+                                current_response=resp,
+                                last_global_middleware_name=middleware.name,
+                            )
+                        except BoltUnhandledRequestError as e:
+                            await self._async_listener_runner.listener_error_handler.handle(
+                                error=e,
+                                request=req,
+                                response=resp,
+                            )
+                        return resp
+                    self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                    return resp
+                return resp
+
+        for listener in self._async_listeners:
+            listener_name = get_name_for_callable(listener.ack_function)
+            self._framework_logger.debug(debug_checking_listener(listener_name))
+            if await listener.async_matches(req=req, resp=resp):  # type: ignore[arg-type]
+                # run all the middleware attached to this listener first
+                middleware_resp, next_was_not_called = await listener.run_async_middleware(
+                    req=req, resp=resp  # type: ignore[arg-type]
+                )
+                if next_was_not_called:
+                    if middleware_resp is not None:
+                        if self._framework_logger.level <= logging.DEBUG:
+                            debug_message = debug_return_listener_middleware_response(
+                                listener_name,
+                                middleware_resp.status,
+                                middleware_resp.body,
+                                starting_time,
+                            )
+                            self._framework_logger.debug(debug_message)
+                        return middleware_resp
+                    # The last listener middleware didn't call next() method.
+                    # This means the listener is not for this incoming request.
+                    continue
+
+                if middleware_resp is not None:
+                    resp = middleware_resp
+
+                self._framework_logger.debug(debug_running_listener(listener_name))
+                listener_response: Optional[BoltResponse] = await self._async_listener_runner.run(
+                    request=req,
+                    response=resp,  # type: ignore[arg-type]
+                    listener_name=listener_name,
+                    listener=listener,
+                )
+                if listener_response is not None:
+                    return listener_response
+
+        if resp is None:
+            resp = BoltResponse(status=404, body={"error": "unhandled request"})
+        if self._raise_error_for_unhandled_request is True:
+            try:
+                raise BoltUnhandledRequestError(
+                    request=req,
+                    current_response=resp,
+                )
+            except BoltUnhandledRequestError as e:
+                await self._async_listener_runner.listener_error_handler.handle(
+                    error=e,
+                    request=req,
+                    response=resp,
+                )
+            return resp
+        return self._handle_unmatched_requests(req, resp)
+
+    except Exception as error:
+        resp = BoltResponse(status=500, body="")
+        await self._async_middleware_error_handler.handle(
+            error=error,
+            request=req,
+            response=resp,
+        )
+        return resp
+
+

Applies all middleware and dispatches an incoming request from Slack to the right code path.

+

Args

+
+
req
+
An incoming request from Slack.
+
+

Returns

+

The response generated by this Bolt app.

+
+
+def attachment_action(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def attachment_action(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `interactive_message` action listener.
+    Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.attachment_action(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new interactive_message action listener. +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

+
+
+def block_action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def block_action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `block_actions` action listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_action(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_actions action listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

+
+
+def block_suggestion(self,
action_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def block_suggestion(
+    self,
+    action_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `block_suggestion` listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_suggestion(action_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_suggestion listener.

+
+
+def command(self,
command: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def command(
+    self,
+    command: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new slash command listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.command("/echo")
+        async def repeat_text(ack, say, command):
+            # Acknowledge command request
+            await ack()
+            await say(f"{command['text']}")
+
+        # Pass a function to this method
+        app.command("/echo")(repeat_text)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        command: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.command(command, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new slash command listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.command("/echo")
+async def repeat_text(ack, say, command):
+    # Acknowledge command request
+    await ack()
+    await say(f"{command['text']}")
+
+# Pass a function to this method
+app.command("/echo")(repeat_text)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
command
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def default_app_uninstalled_event_listener(self) ‑> Callable[..., Awaitable[BoltResponse | None]] +
+
+
+ +Expand source code + +
def default_app_uninstalled_event_listener(
+    self,
+) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+    if self._async_tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._async_tokens_revocation_listeners.handle_app_uninstalled_events
+
+
+
+
+def default_tokens_revoked_event_listener(self) ‑> Callable[..., Awaitable[BoltResponse | None]] +
+
+
+ +Expand source code + +
def default_tokens_revoked_event_listener(
+    self,
+) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+    if self._async_tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._async_tokens_revocation_listeners.handle_tokens_revoked_events
+
+
+
+
+def dialog_cancellation(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def dialog_cancellation(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `dialog_submission` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_cancellation(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_submission listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_submission(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def dialog_submission(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `dialog_submission` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_submission(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_submission listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_suggestion(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def dialog_suggestion(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `dialog_suggestion` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_suggestion(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_suggestion listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def enable_token_revocation_listeners(self) ‑> None +
+
+
+ +Expand source code + +
def enable_token_revocation_listeners(self) -> None:
+    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+
+
+
+def error(self,
func: Callable[..., Awaitable[BoltResponse | None]]) ‑> Callable[..., Awaitable[BoltResponse | None]]
+
+
+
+ +Expand source code + +
def error(
+    self, func: Callable[..., Awaitable[Optional[BoltResponse]]]
+) -> Callable[..., Awaitable[Optional[BoltResponse]]]:
+    """Updates the global error handler. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.error
+        async def custom_error_handler(error, body, logger):
+            logger.exception(f"Error: {error}")
+            logger.info(f"Request body: {body}")
+
+        # Pass a function to this method
+        app.error(custom_error_handler)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        func: The function that is supposed to be executed
+            when getting an unhandled error in Bolt app.
+    """
+    if not is_callable_coroutine(func):
+        name = get_name_for_callable(func)
+        raise BoltError(error_listener_function_must_be_coro_func(name))
+    self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    return func
+
+

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

+
# Use this method as a decorator
+@app.error
+async def custom_error_handler(error, body, logger):
+    logger.exception(f"Error: {error}")
+    logger.info(f"Request body: {body}")
+
+# Pass a function to this method
+app.error(custom_error_handler)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
func
+
The function that is supposed to be executed +when getting an unhandled error in Bolt app.
+
+
+
+def event(self,
event: str | Pattern | Dict[str, str | Sequence[str | Pattern | None] | None],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def event(
+    self,
+    event: Union[
+        str,
+        Pattern,
+        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+    ],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new event listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.event("team_join")
+        async def ask_for_introduction(event, say):
+            welcome_channel_id = "C12345"
+            user_id = event["user"]
+            text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+            await say(text=text, channel=welcome_channel_id)
+
+        # Pass a function to this method
+        app.event("team_join")(ask_for_introduction)
+
+    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        event: The conditions that match a request payload.
+            If you pass a dict for this, you can have type, subtype in the constraint.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    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__
+
+

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

+
# Use this method as a decorator
+@app.event("team_join")
+async def ask_for_introduction(event, say):
+    welcome_channel_id = "C12345"
+    user_id = event["user"]
+    text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+    await say(text=text, channel=welcome_channel_id)
+
+# Pass a function to this method
+app.event("team_join")(ask_for_introduction)
+
+

Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
event
+
The conditions that match a request payload. +If you pass a dict for this, you can have type, subtype in the constraint.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def function(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None,
auto_acknowledge: bool = True,
ack_timeout: int = 3) ‑> Callable[..., Callable[..., Awaitable[BoltResponse]] | None]
+
+
+
+ +Expand source code + +
def function(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+    auto_acknowledge: bool = True,
+    ack_timeout: int = 3,
+) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]:
+    """Registers a new Function listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.function("reverse")
+        async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail):
+            try:
+                await ack()
+                string_to_reverse = inputs["stringToReverse"]
+                await complete({"reverseString": string_to_reverse[::-1]})
+            except Exception as e:
+                await fail(f"Cannot reverse string (error: {e})")
+                raise e
+
+        # Pass a function to this method
+        app.function("reverse")(reverse_string)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        callback_id: The callback id to identify the function
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    if auto_acknowledge is True:
+        if ack_timeout != 3:
+            self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.function_executed(
+            callback_id=callback_id, base_logger=self._base_logger, asyncio=True
+        )
+        return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.function("reverse")
+async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail):
+    try:
+        await ack()
+        string_to_reverse = inputs["stringToReverse"]
+        await complete({"reverseString": string_to_reverse[::-1]})
+    except Exception as e:
+        await fail(f"Cannot reverse string (error: {e})")
+        raise e
+
+# Pass a function to this method
+app.function("reverse")(reverse_string)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
callback_id
+
The callback id to identify the function
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def global_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def global_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new global shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.global_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new global shortcut listener.

+
+
+def message(self,
keyword: str | Pattern = '',
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def message(
+    self,
+    keyword: Union[str, Pattern] = "",
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new message event listener. This method can be used as either a decorator or a method.
+    Check the `App#event` method's docstring for details.
+
+        # Use this method as a decorator
+        @app.message(":wave:")
+        async def say_hello(message, say):
+            user = message['user']
+            await say(f"Hi there, <@{user}>!")
+
+        # Pass a function to this method
+        app.message(":wave:")(say_hello)
+
+    Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        keyword: The keyword to match
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        constraints = {
+            "type": "message",
+            "subtype": (
+                # In most cases, new message events come with no subtype.
+                None,
+                # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                # By contrast, messages posted using classic app's bot token still have the subtype.
+                "bot_message",
+                # If an end-user posts a message with "Also send to #channel" checked,
+                # the message event comes with this subtype.
+                "thread_broadcast",
+                # If an end-user posts a message with attached files,
+                # the message event comes with this subtype.
+                "file_share",
+            ),
+        }
+        primary_matcher = builtin_matchers.message_event(
+            constraints=constraints,
+            keyword=keyword,
+            asyncio=True,
+            base_logger=self._base_logger,
+        )
+        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)
+
+    return __call__
+
+

Registers a new message event listener. This method can be used as either a decorator or a method. +Check the App#event method's docstring for details.

+
# Use this method as a decorator
+@app.message(":wave:")
+async def say_hello(message, say):
+    user = message['user']
+    await say(f"Hi there, <@{user}>!")
+
+# Pass a function to this method
+app.message(":wave:")(say_hello)
+
+

Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
keyword
+
The keyword to match
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def message_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def message_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new message shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.message_shortcut(callback_id, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new message shortcut listener.

+
+
+def middleware(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def middleware(self, *args) -> Optional[Callable]:
+    """Registers a new middleware to this app.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.middleware
+        async def middleware_func(logger, body, next):
+            logger.info(f"request body: {body}")
+            await next()
+
+        # Pass a function to this method
+        app.middleware(middleware_func)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        *args: A function that works as a global middleware.
+    """
+    if len(args) > 0:
+        middleware_or_callable = args[0]
+        if isinstance(middleware_or_callable, AsyncMiddleware):
+            middleware: AsyncMiddleware = middleware_or_callable
+            self._async_middleware_list.append(middleware)
+            if isinstance(middleware, AsyncAssistant) and middleware.thread_context_store is not None:
+                self._assistant_thread_context_store = middleware.thread_context_store
+        elif callable(middleware_or_callable):
+            self._async_middleware_list.append(
+                AsyncCustomMiddleware(
+                    app_name=self.name,
+                    func=middleware_or_callable,
+                    base_logger=self._base_logger,
+                )
+            )
+            return middleware_or_callable
+        else:
+            raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+    return None
+
+

Registers a new middleware to this app. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.middleware
+async def middleware_func(logger, body, next):
+    logger.info(f"request body: {body}")
+    await next()
+
+# Pass a function to this method
+app.middleware(middleware_func)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
*args
+
A function that works as a global middleware.
+
+
+
+def options(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def options(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new options listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.options("menu_selection")
+        async def show_menu_options(ack):
+            options = [
+                {
+                    "text": {"type": "plain_text", "text": "Option 1"},
+                    "value": "1-1",
+                },
+                {
+                    "text": {"type": "plain_text", "text": "Option 2"},
+                    "value": "1-2",
+                },
+            ]
+            await ack(options=options)
+
+        # Pass a function to this method
+        app.options("menu_selection")(show_menu_options)
+
+    Refer to the following documents for details:
+
+    * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+    * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.options(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.options("menu_selection")
+async def show_menu_options(ack):
+    options = [
+        {
+            "text": {"type": "plain_text", "text": "Option 1"},
+            "value": "1-1",
+        },
+        {
+            "text": {"type": "plain_text", "text": "Option 2"},
+            "value": "1-2",
+        },
+    ]
+    await ack(options=options)
+
+# Pass a function to this method
+app.options("menu_selection")(show_menu_options)
+
+

Refer to the following documents for details:

+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def server(self, port: int = 3000, path: str = '/slack/events', host: str | None = None) ‑> AsyncSlackAppServer +
+
+
+ +Expand source code + +
def server(
+    self,
+    port: int = 3000,
+    path: str = "/slack/events",
+    host: Optional[str] = None,
+) -> AsyncSlackAppServer:
+    """Configure a web server using AIOHTTP.
+    Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+    Args:
+        port: The port to listen on (Default: 3000)
+        path: The path to handle request from Slack (Default: `/slack/events`)
+        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+    """
+    if self._server is None or self._server.port != port or self._server.path != path:
+        self._server = AsyncSlackAppServer(
+            port=port,
+            path=path,
+            app=self,
+            host=host,
+        )
+    return self._server
+
+

Configure a web server using AIOHTTP. +Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.

+

Args

+
+
port
+
The port to listen on (Default: 3000)
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
host
+
The hostname to serve the web endpoints. (Default: 0.0.0.0)
+
+
+
+def shortcut(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def shortcut(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new shortcut listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.shortcut("open_modal")
+        async def open_modal(ack, body, client):
+            # Acknowledge the command request
+            await ack()
+            # Call views_open with the built-in client
+            await client.views_open(
+                # Pass a valid trigger_id within 3 seconds of receiving it
+                trigger_id=body["trigger_id"],
+                # View payload
+                view={ ... }
+            )
+
+        # Pass a function to this method
+        app.shortcut("open_modal")(open_modal)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.shortcut(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.shortcut("open_modal")
+async def open_modal(ack, body, client):
+    # Acknowledge the command request
+    await ack()
+    # Call views_open with the built-in client
+    await client.views_open(
+        # Pass a valid trigger_id within 3 seconds of receiving it
+        trigger_id=body["trigger_id"],
+        # View payload
+        view={ ... }
+    )
+
+# Pass a function to this method
+app.shortcut("open_modal")(open_modal)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def start(self, port: int = 3000, path: str = '/slack/events', host: str | None = None) ‑> None +
+
+
+ +Expand source code + +
def start(self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None) -> None:
+    """Start a web server using AIOHTTP.
+    Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.
+
+    Args:
+        port: The port to listen on (Default: 3000)
+        path: The path to handle request from Slack (Default: `/slack/events`)
+        host: The hostname to serve the web endpoints. (Default: 0.0.0.0)
+    """
+    self.server(port=port, path=path, host=host).start()
+
+

Start a web server using AIOHTTP. +Refer to https://docs.aiohttp.org/ for more details about AIOHTTP.

+

Args

+
+
port
+
The port to listen on (Default: 3000)
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
host
+
The hostname to serve the web endpoints. (Default: 0.0.0.0)
+
+
+
+def step(self,
callback_id: str | Pattern | AsyncWorkflowStep | AsyncWorkflowStepBuilder,
edit: Callable[..., BoltResponse | None] | AsyncListener | Sequence[Callable] | None = None,
save: Callable[..., BoltResponse | None] | AsyncListener | Sequence[Callable] | None = None,
execute: Callable[..., BoltResponse | None] | AsyncListener | Sequence[Callable] | None = None)
+
+
+
+ +Expand source code + +
def step(
+    self,
+    callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder],
+    edit: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+    save: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+    execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new step from app listener.
+
+    Unlike others, this method doesn't behave as a decorator.
+    If you want to register a step from app by a decorator, use `AsyncWorkflowStepBuilder`'s methods.
+
+        # Create a new WorkflowStep instance
+        from slack_bolt.workflows.async_step import AsyncWorkflowStep
+        ws = AsyncWorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        # Pass Step to set up listeners
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+    For further information about AsyncWorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        callback_id: The Callback ID for this step from app
+        edit: The function for displaying a modal in the Workflow Builder
+        save: The function for handling configuration in the Workflow Builder
+        execute: The function for handling the step execution
+    """
+    warnings.warn(
+        (
+            "Steps from apps for legacy workflows are now deprecated. "
+            "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+        ),
+        category=DeprecationWarning,
+    )
+    step = callback_id
+    if isinstance(callback_id, (str, Pattern)):
+        step = AsyncWorkflowStep(
+            callback_id=callback_id,
+            edit=edit,  # type: ignore[arg-type]
+            save=save,  # type: ignore[arg-type]
+            execute=execute,  # type: ignore[arg-type]
+            base_logger=self._base_logger,
+        )
+    elif isinstance(step, AsyncWorkflowStepBuilder):
+        step = step.build(base_logger=self._base_logger)
+    elif not isinstance(step, AsyncWorkflowStep):
+        raise BoltError(f"Invalid step object ({type(step)})")
+
+    self.use(AsyncWorkflowStepMiddleware(step))
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new step from app listener.

+

Unlike others, this method doesn't behave as a decorator. +If you want to register a step from app by a decorator, use AsyncWorkflowStepBuilder's methods.

+
# Create a new WorkflowStep instance
+from slack_bolt.workflows.async_step import AsyncWorkflowStep
+ws = AsyncWorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+# Pass Step to set up listeners
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document. +For further information about AsyncWorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to the async prefixed ones in slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
callback_id
+
The Callback ID for this step from app
+
edit
+
The function for displaying a modal in the Workflow Builder
+
save
+
The function for handling configuration in the Workflow Builder
+
execute
+
The function for handling the step execution
+
+
+
+def use(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def use(self, *args) -> Optional[Callable]:
+    """Refer to `AsyncApp#middleware()` method's docstring for details."""
+    return self.middleware(*args)
+
+

Refer to AsyncApp#middleware() method's docstring for details.

+
+
+def view(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def view(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `view_submission`/`view_closed` event listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.view("view_1")
+        async def handle_submission(ack, body, client, view):
+            # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+            hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+            user = body["user"]["id"]
+            # Validate the inputs
+            errors = {}
+            if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                errors["block_c"] = "The value must be longer than 5 characters"
+            if len(errors) > 0:
+                await ack(response_action="errors", errors=errors)
+                return
+            # Acknowledge the view_submission event and close the modal
+            await ack()
+            # Do whatever you want with the input data - here we're saving it to a DB
+
+        # Pass a function to this method
+        app.view("view_1")(handle_submission)
+
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission/view_closed event listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.view("view_1")
+async def handle_submission(ack, body, client, view):
+    # Assume there's an input block with <code>block\_c</code> as the block_id and <code>dreamy\_input</code>
+    hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+    user = body["user"]["id"]
+    # Validate the inputs
+    errors = {}
+    if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+        errors["block_c"] = "The value must be longer than 5 characters"
+    if len(errors) > 0:
+        await ack(response_action="errors", errors=errors)
+        return
+    # Acknowledge the view_submission event and close the modal
+    await ack()
+    # Do whatever you want with the input data - here we're saving it to a DB
+
+# Pass a function to this method
+app.view("view_1")(handle_submission)
+
+

Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.async_args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def view_closed(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def view_closed(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `view_closed` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_closed(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+ +
+
+def view_submission(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., Awaitable[bool]]] | None = None,
middleware: Sequence[Callable | AsyncMiddleware] | None = None) ‑> Callable[..., Callable[..., Awaitable[BoltResponse | None]] | None]
+
+
+
+ +Expand source code + +
def view_submission(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None,
+    middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]:
+    """Registers a new `view_submission` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+    details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_submission(constraints, asyncio=True, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

+
+
+def web_app(self, path: str = '/slack/events', port: int = 3000) ‑> aiohttp.web_app.Application +
+
+
+ +Expand source code + +
def web_app(self, path: str = "/slack/events", port: int = 3000) -> web.Application:
+    """Returns a `web.Application` instance for aiohttp-devtools users.
+
+        from slack_bolt.async_app import AsyncApp
+        app = AsyncApp()
+
+        @app.event("app_mention")
+        async def event_test(body, say, logger):
+            logger.info(body)
+            await say("What's up?")
+
+        def app_factory():
+            return app.web_app()
+
+        # adev runserver --port 3000 --app-factory app_factory async_app.py
+
+    Args:
+        path: The path to receive incoming requests from Slack
+        port: The port to listen on (Default: 3000)
+    """
+    return self.server(path=path, port=port).web_app
+
+

Returns a web.Application instance for aiohttp-devtools users.

+
from slack_bolt.async_app import AsyncApp
+app = AsyncApp()
+
+@app.event("app_mention")
+async def event_test(body, say, logger):
+    logger.info(body)
+    await say("What's up?")
+
+def app_factory():
+    return app.web_app()
+
+# adev runserver --port 3000 --app-factory app_factory async_app.py
+
+

Args

+
+
path
+
The path to receive incoming requests from Slack
+
port
+
The port to listen on (Default: 3000)
+
+
+
+
+
+class AsyncAssistant +(*,
app_name: str = 'assistant',
thread_context_store: AsyncAssistantThreadContextStore | None = None,
logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncAssistant(AsyncMiddleware):
+    _thread_started_listeners: Optional[List[AsyncListener]]
+    _user_message_listeners: Optional[List[AsyncListener]]
+    _bot_message_listeners: Optional[List[AsyncListener]]
+    _thread_context_changed_listeners: Optional[List[AsyncListener]]
+
+    thread_context_store: Optional[AsyncAssistantThreadContextStore]
+    base_logger: Optional[logging.Logger]
+
+    def __init__(
+        self,
+        *,
+        app_name: str = "assistant",
+        thread_context_store: Optional[AsyncAssistantThreadContextStore] = None,
+        logger: Optional[logging.Logger] = None,
+    ):
+        self.app_name = app_name
+        self.thread_context_store = thread_context_store
+        self.base_logger = logger
+
+        self._thread_started_listeners = None
+        self._thread_context_changed_listeners = None
+        self._user_message_listeners = None
+        self._bot_message_listeners = None
+
+    def thread_started(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_started_listeners is None:
+            self._thread_started_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_assistant_thread_started_event,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def user_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._user_message_listeners is None:
+            self._user_message_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_user_message_event_in_assistant_thread,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def bot_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._bot_message_listeners is None:
+            self._bot_message_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_bot_message_event_in_assistant_thread,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def thread_context_changed(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_context_changed_listeners is None:
+            self._thread_context_changed_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_assistant_thread_context_changed_event,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    @staticmethod
+    def _merge_matchers(
+        primary_matcher: Union[Callable[..., bool], AsyncListenerMatcher],
+        custom_matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]],
+    ):
+        return [primary_matcher] + (custom_matchers or [])  # type: ignore[operator]
+
+    @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]
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> Optional[BoltResponse]:
+        if self._thread_context_changed_listeners is None:
+            self.thread_context_changed(self.default_thread_context_changed)
+
+        listener_runner: AsyncioListenerRunner = req.context.listener_runner
+        for listeners in [
+            self._thread_started_listeners,
+            self._thread_context_changed_listeners,
+            self._user_message_listeners,
+            self._bot_message_listeners,
+        ]:
+            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,
+                            listener_name="assistant_listener",
+                            listener=listener,
+                        )
+        if is_other_message_sub_event_in_assistant_thread(req.body):
+            # message_changed, message_deleted, etc.
+            return await req.context.ack()
+
+        await next()
+
+    def build_listener(
+        self,
+        listener_or_functions: Union[AsyncListener, Callable, List[Callable]],
+        matchers: Optional[List[Union[AsyncListenerMatcher, Callable[..., Awaitable[bool]]]]] = None,
+        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, 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)
+
+            matchers = matchers if matchers else []
+            listener_matchers: List[AsyncListenerMatcher] = []
+            for matcher in matchers:
+                if isinstance(matcher, AsyncListenerMatcher):
+                    listener_matchers.append(matcher)
+                else:
+                    listener_matchers.append(
+                        build_listener_matcher(
+                            func=matcher,  # type: ignore[arg-type]
+                            asyncio=True,
+                            base_logger=base_logger,
+                        )
+                    )
+            return AsyncCustomListener(
+                app_name=self.app_name,
+                matchers=listener_matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=True,
+                base_logger=base_logger or self.base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+

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

+

Ancestors

+ +

Class variables

+
+
var base_logger : logging.Logger | None
+
+

The type of the None singleton.

+
+
var thread_context_storeAsyncAssistantThreadContextStore | None
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+async def default_thread_context_changed(save_thread_context: AsyncSaveThreadContext,
payload: dict)
+
+
+
+ +Expand source code + +
@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)
+
+
+
+
+

Methods

+
+
+def bot_message(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def bot_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._bot_message_listeners is None:
+        self._bot_message_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_bot_message_event_in_assistant_thread,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def build_listener(self,
listener_or_functions: AsyncListener | Callable | List[Callable],
matchers: List[AsyncListenerMatcher | Callable[..., Awaitable[bool]]] | None = None,
middleware: List[AsyncMiddleware] | None = None,
base_logger: logging.Logger | None = None) ‑> AsyncListener
+
+
+
+ +Expand source code + +
def build_listener(
+    self,
+    listener_or_functions: Union[AsyncListener, Callable, List[Callable]],
+    matchers: Optional[List[Union[AsyncListenerMatcher, Callable[..., Awaitable[bool]]]]] = None,
+    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, 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)
+
+        matchers = matchers if matchers else []
+        listener_matchers: List[AsyncListenerMatcher] = []
+        for matcher in matchers:
+            if isinstance(matcher, AsyncListenerMatcher):
+                listener_matchers.append(matcher)
+            else:
+                listener_matchers.append(
+                    build_listener_matcher(
+                        func=matcher,  # type: ignore[arg-type]
+                        asyncio=True,
+                        base_logger=base_logger,
+                    )
+                )
+        return AsyncCustomListener(
+            app_name=self.app_name,
+            matchers=listener_matchers,
+            middleware=middleware,
+            ack_function=ack_function,
+            lazy_functions=functions,
+            auto_acknowledgement=True,
+            base_logger=base_logger or self.base_logger,
+        )
+    else:
+        raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+
+
+
+def thread_context_changed(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_context_changed(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_context_changed_listeners is None:
+        self._thread_context_changed_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_assistant_thread_context_changed_event,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def thread_started(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_started(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_started_listeners is None:
+        self._thread_started_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_assistant_thread_started_event,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def user_message(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def user_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._user_message_listeners is None:
+        self._user_message_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_user_message_event_in_assistant_thread,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+

Inherited members

+ +
+
+class AsyncBoltContext +(*args, **kwargs) +
+
+
+ +Expand source code + +
class AsyncBoltContext(BaseContext):
+    """Context object associated with a request from Slack."""
+
+    def to_copyable(self) -> "AsyncBoltContext":
+        new_dict = {}
+        for prop_name, prop_value in self.items():
+            if prop_name in self.copyable_standard_property_names:
+                # all the standard properties are copiable
+                new_dict[prop_name] = prop_value
+            elif prop_name in self.non_copyable_standard_property_names:
+                # Do nothing with this property (e.g., listener_runner)
+                continue
+            else:
+                try:
+                    copied_value = create_copy(prop_value)
+                    new_dict[prop_name] = copied_value
+                except TypeError as te:
+                    self.logger.debug(
+                        f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                        f"as it's not possible to make a deep copy (error: {te})"
+                    )
+        return AsyncBoltContext(new_dict)
+
+    # The return type is intentionally string to avoid circular imports
+    @property
+    def listener_runner(self) -> "AsyncioListenerRunner":
+        """The properly configured listener_runner that is available for middleware/listeners."""
+        return self["listener_runner"]
+
+    @property
+    def client(self) -> AsyncWebClient:
+        """The `AsyncWebClient` instance available for this request.
+
+            @app.event("app_mention")
+            async def handle_events(context):
+                await context.client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+            # You can access "client" this way too.
+            @app.event("app_mention")
+            async def handle_events(client, context):
+                await client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+        Returns:
+            `AsyncWebClient` instance
+        """
+        if "client" not in self:
+            self["client"] = AsyncWebClient(token=None)
+        return self["client"]
+
+    @property
+    def ack(self) -> AsyncAck:
+        """`ack()` function for this request.
+
+            @app.action("button")
+            async def handle_button_clicks(context):
+                await context.ack()
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            async def handle_button_clicks(ack):
+                await ack()
+
+        Returns:
+            Callable `ack()` function
+        """
+        if "ack" not in self:
+            self["ack"] = AsyncAck()
+        return self["ack"]
+
+    @property
+    def say(self) -> AsyncSay:
+        """`say()` function for this request.
+
+            @app.action("button")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.say("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            async def handle_button_clicks(ack, say):
+                await ack()
+                await say("Hi!")
+
+        Returns:
+            Callable `say()` function
+        """
+        if "say" not in self:
+            self["say"] = AsyncSay(client=self.client, channel=self.channel_id)
+        return self["say"]
+
+    @property
+    def respond(self) -> Optional[AsyncRespond]:
+        """`respond()` function for this request.
+
+            @app.action("button")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.respond("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            async def handle_button_clicks(ack, respond):
+                await ack()
+                await respond("Hi!")
+
+        Returns:
+            Callable `respond()` function
+        """
+        if "respond" not in self:
+            self["respond"] = AsyncRespond(
+                response_url=self.response_url,
+                proxy=self.client.proxy,
+                ssl=self.client.ssl,
+            )
+        return self["respond"]
+
+    @property
+    def complete(self) -> AsyncComplete:
+        """`complete()` function for this request. Once a custom function's state is set to complete,
+        any outputs the function returns will be passed along to the next step of its housing workflow,
+        or complete the workflow if the function is the last step in a workflow. Additionally,
+        any interactivity handlers associated to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            async def handle_button_clicks(ack, complete):
+                await ack()
+                await complete(outputs={"stringReverse":"olleh"})
+
+            @app.function("reverse")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.complete(outputs={"stringReverse":"olleh"})
+
+        Returns:
+            Callable `complete()` function
+        """
+        if "complete" not in self:
+            self["complete"] = AsyncComplete(client=self.client, function_execution_id=self.function_execution_id)
+        return self["complete"]
+
+    @property
+    def fail(self) -> AsyncFail:
+        """`fail()` function for this request. Once a custom function's state is set to error,
+        its housing workflow will be interrupted and any provided error message will be passed
+        on to the end user through SlackBot. Additionally, any interactivity handlers associated
+        to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            async def handle_button_clicks(ack, fail):
+                await ack()
+                await fail(error="something went wrong")
+
+            @app.function("reverse")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.fail(error="something went wrong")
+
+        Returns:
+            Callable `fail()` function
+        """
+        if "fail" not in self:
+            self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id)
+        return self["fail"]
+
+    @property
+    def set_title(self) -> Optional[AsyncSetTitle]:
+        return self.get("set_title")
+
+    @property
+    def set_status(self) -> Optional[AsyncSetStatus]:
+        return self.get("set_status")
+
+    @property
+    def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]:
+        return self.get("set_suggested_prompts")
+
+    @property
+    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")
+
+

Context object associated with a request from Slack.

+

Ancestors

+ +

Instance variables

+
+
prop ackAsyncAck
+
+
+ +Expand source code + +
@property
+def ack(self) -> AsyncAck:
+    """`ack()` function for this request.
+
+        @app.action("button")
+        async def handle_button_clicks(context):
+            await context.ack()
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        async def handle_button_clicks(ack):
+            await ack()
+
+    Returns:
+        Callable `ack()` function
+    """
+    if "ack" not in self:
+        self["ack"] = AsyncAck()
+    return self["ack"]
+
+

ack() function for this request.

+
@app.action("button")
+async def handle_button_clicks(context):
+    await context.ack()
+
+# You can access "ack" this way too.
+@app.action("button")
+async def handle_button_clicks(ack):
+    await ack()
+
+

Returns

+

Callable ack() function

+
+
prop client : slack_sdk.web.async_client.AsyncWebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> AsyncWebClient:
+    """The `AsyncWebClient` instance available for this request.
+
+        @app.event("app_mention")
+        async def handle_events(context):
+            await context.client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+        # You can access "client" this way too.
+        @app.event("app_mention")
+        async def handle_events(client, context):
+            await client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+    Returns:
+        `AsyncWebClient` instance
+    """
+    if "client" not in self:
+        self["client"] = AsyncWebClient(token=None)
+    return self["client"]
+
+

The AsyncWebClient instance available for this request.

+
@app.event("app_mention")
+async def handle_events(context):
+    await context.client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+# You can access "client" this way too.
+@app.event("app_mention")
+async def handle_events(client, context):
+    await client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+

Returns

+

AsyncWebClient instance

+
+
prop completeAsyncComplete
+
+
+ +Expand source code + +
@property
+def complete(self) -> AsyncComplete:
+    """`complete()` function for this request. Once a custom function's state is set to complete,
+    any outputs the function returns will be passed along to the next step of its housing workflow,
+    or complete the workflow if the function is the last step in a workflow. Additionally,
+    any interactivity handlers associated to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        async def handle_button_clicks(ack, complete):
+            await ack()
+            await complete(outputs={"stringReverse":"olleh"})
+
+        @app.function("reverse")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.complete(outputs={"stringReverse":"olleh"})
+
+    Returns:
+        Callable `complete()` function
+    """
+    if "complete" not in self:
+        self["complete"] = AsyncComplete(client=self.client, function_execution_id=self.function_execution_id)
+    return self["complete"]
+
+

complete() function for this request. Once a custom function's state is set to complete, +any outputs the function returns will be passed along to the next step of its housing workflow, +or complete the workflow if the function is the last step in a workflow. Additionally, +any interactivity handlers associated to a function invocation will no longer be invocable.

+
@app.function("reverse")
+async def handle_button_clicks(ack, complete):
+    await ack()
+    await complete(outputs={"stringReverse":"olleh"})
+
+@app.function("reverse")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.complete(outputs={"stringReverse":"olleh"})
+
+

Returns

+

Callable complete() function

+
+
prop failAsyncFail
+
+
+ +Expand source code + +
@property
+def fail(self) -> AsyncFail:
+    """`fail()` function for this request. Once a custom function's state is set to error,
+    its housing workflow will be interrupted and any provided error message will be passed
+    on to the end user through SlackBot. Additionally, any interactivity handlers associated
+    to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        async def handle_button_clicks(ack, fail):
+            await ack()
+            await fail(error="something went wrong")
+
+        @app.function("reverse")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.fail(error="something went wrong")
+
+    Returns:
+        Callable `fail()` function
+    """
+    if "fail" not in self:
+        self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id)
+    return self["fail"]
+
+

fail() function for this request. Once a custom function's state is set to error, +its housing workflow will be interrupted and any provided error message will be passed +on to the end user through SlackBot. Additionally, any interactivity handlers associated +to a function invocation will no longer be invocable.

+
@app.function("reverse")
+async def handle_button_clicks(ack, fail):
+    await ack()
+    await fail(error="something went wrong")
+
+@app.function("reverse")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.fail(error="something went wrong")
+
+

Returns

+

Callable fail() function

+
+
prop get_thread_contextAsyncGetThreadContext | None
+
+
+ +Expand source code + +
@property
+def get_thread_context(self) -> Optional[AsyncGetThreadContext]:
+    return self.get("get_thread_context")
+
+
+
+
prop listener_runner : AsyncioListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> "AsyncioListenerRunner":
+    """The properly configured listener_runner that is available for middleware/listeners."""
+    return self["listener_runner"]
+
+

The properly configured listener_runner that is available for middleware/listeners.

+
+
prop respondAsyncRespond | None
+
+
+ +Expand source code + +
@property
+def respond(self) -> Optional[AsyncRespond]:
+    """`respond()` function for this request.
+
+        @app.action("button")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.respond("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        async def handle_button_clicks(ack, respond):
+            await ack()
+            await respond("Hi!")
+
+    Returns:
+        Callable `respond()` function
+    """
+    if "respond" not in self:
+        self["respond"] = AsyncRespond(
+            response_url=self.response_url,
+            proxy=self.client.proxy,
+            ssl=self.client.ssl,
+        )
+    return self["respond"]
+
+

respond() function for this request.

+
@app.action("button")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.respond("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+async def handle_button_clicks(ack, respond):
+    await ack()
+    await respond("Hi!")
+
+

Returns

+

Callable respond() function

+
+
prop save_thread_contextAsyncSaveThreadContext | None
+
+
+ +Expand source code + +
@property
+def save_thread_context(self) -> Optional[AsyncSaveThreadContext]:
+    return self.get("save_thread_context")
+
+
+
+
prop sayAsyncSay
+
+
+ +Expand source code + +
@property
+def say(self) -> AsyncSay:
+    """`say()` function for this request.
+
+        @app.action("button")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.say("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        async def handle_button_clicks(ack, say):
+            await ack()
+            await say("Hi!")
+
+    Returns:
+        Callable `say()` function
+    """
+    if "say" not in self:
+        self["say"] = AsyncSay(client=self.client, channel=self.channel_id)
+    return self["say"]
+
+

say() function for this request.

+
@app.action("button")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.say("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+async def handle_button_clicks(ack, say):
+    await ack()
+    await say("Hi!")
+
+

Returns

+

Callable say() function

+
+
prop say_streamAsyncSayStream | None
+
+
+ +Expand source code + +
@property
+def say_stream(self) -> Optional[AsyncSayStream]:
+    return self.get("say_stream")
+
+
+
+
prop set_statusAsyncSetStatus | None
+
+
+ +Expand source code + +
@property
+def set_status(self) -> Optional[AsyncSetStatus]:
+    return self.get("set_status")
+
+
+
+
prop set_suggested_promptsAsyncSetSuggestedPrompts | None
+
+
+ +Expand source code + +
@property
+def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]:
+    return self.get("set_suggested_prompts")
+
+
+
+
prop set_titleAsyncSetTitle | None
+
+
+ +Expand source code + +
@property
+def set_title(self) -> Optional[AsyncSetTitle]:
+    return self.get("set_title")
+
+
+
+
+

Methods

+
+
+def to_copyable(self) ‑> AsyncBoltContext +
+
+
+ +Expand source code + +
def to_copyable(self) -> "AsyncBoltContext":
+    new_dict = {}
+    for prop_name, prop_value in self.items():
+        if prop_name in self.copyable_standard_property_names:
+            # all the standard properties are copiable
+            new_dict[prop_name] = prop_value
+        elif prop_name in self.non_copyable_standard_property_names:
+            # Do nothing with this property (e.g., listener_runner)
+            continue
+        else:
+            try:
+                copied_value = create_copy(prop_value)
+                new_dict[prop_name] = copied_value
+            except TypeError as te:
+                self.logger.debug(
+                    f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                    f"as it's not possible to make a deep copy (error: {te})"
+                )
+    return AsyncBoltContext(new_dict)
+
+
+
+
+

Inherited members

+ +
+
+class AsyncBoltRequest +(*,
body: str | dict,
query: str | Dict[str, str] | Dict[str, Sequence[str]] | None = None,
headers: Dict[str, str | Sequence[str]] | None = None,
context: Dict[str, Any] | None = None,
mode: str = 'http')
+
+
+
+ +Expand source code + +
class AsyncBoltRequest:
+    raw_body: str
+    body: Dict[str, Any]
+    query: Dict[str, Sequence[str]]
+    headers: Dict[str, Sequence[str]]
+    content_type: Optional[str]
+    context: AsyncBoltContext
+    lazy_only: bool
+    lazy_function_name: Optional[str]
+    mode: str  # either "http" or "socket_mode"
+
+    def __init__(
+        self,
+        *,
+        body: Union[str, dict],
+        query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None,
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+        context: Optional[Dict[str, Any]] = None,
+        mode: str = "http",  # either "http" or "socket_mode"
+    ):
+        """Request to a Bolt app.
+
+        Args:
+            body: The raw request body (only plain text is supported for "http" mode)
+            query: The query string data in any data format.
+            headers: The request headers.
+            context: The context in this request.
+            mode: The mode used for this request. (either "http" or "socket_mode")
+        """
+
+        if mode == "http":
+            # HTTP Mode
+            if body is not None and not isinstance(body, str):
+                raise BoltError(error_message_raw_body_required_in_http_mode())
+            self.raw_body = body if body is not None else ""
+        else:
+            # Socket Mode
+            if body is not None and isinstance(body, str):
+                self.raw_body = body
+            else:
+                # We don't convert the dict value to str
+                # as doing so does not guarantee to keep the original structure/format.
+                self.raw_body = ""
+
+        self.query = parse_query(query)
+        self.headers = build_normalized_headers(headers)
+        self.content_type = extract_content_type(self.headers)
+
+        if isinstance(body, str):
+            self.body = parse_body(self.raw_body, self.content_type)
+        elif isinstance(body, dict):
+            self.body = body
+        else:
+            self.body = {}
+
+        self.context = build_async_context(AsyncBoltContext(context if context else {}), self.body)
+        self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0])
+        self.lazy_function_name = self.headers.get("x-slack-bolt-lazy-function-name", [None])[0]
+        self.mode = mode
+
+    def to_copyable(self) -> "AsyncBoltRequest":
+        body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+        return AsyncBoltRequest(
+            body=body,
+            query=self.query,
+            headers=self.headers,
+            context=self.context.to_copyable(),
+            mode=self.mode,
+        )
+
+

Request to a Bolt app.

+

Args

+
+
body
+
The raw request body (only plain text is supported for "http" mode)
+
query
+
The query string data in any data format.
+
headers
+
The request headers.
+
context
+
The context in this request.
+
mode
+
The mode used for this request. (either "http" or "socket_mode")
+
+

Class variables

+
+
var body : Dict[str, Any]
+
+

The type of the None singleton.

+
+
var content_type : str | None
+
+

The type of the None singleton.

+
+
var contextAsyncBoltContext
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var lazy_function_name : str | None
+
+

The type of the None singleton.

+
+
var lazy_only : bool
+
+

The type of the None singleton.

+
+
var mode : str
+
+

The type of the None singleton.

+
+
var query : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var raw_body : str
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def to_copyable(self) ‑> AsyncBoltRequest +
+
+
+ +Expand source code + +
def to_copyable(self) -> "AsyncBoltRequest":
+    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+    return AsyncBoltRequest(
+        body=body,
+        query=self.query,
+        headers=self.headers,
+        context=self.context.to_copyable(),
+        mode=self.mode,
+    )
+
+
+
+
+
+
+class AsyncCustomListenerMatcher +(*,
app_name: str,
func: Callable[..., Awaitable[bool]],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncCustomListenerMatcher(AsyncListenerMatcher):
+    app_name: str
+    func: Callable[..., Awaitable[bool]]
+    arg_names: Sequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]], base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
+        return await self.func(
+            **build_async_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,  # type: ignore[arg-type]
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : Sequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., Awaitable[bool]]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class AsyncGetThreadContext +(thread_context_store: AsyncAssistantThreadContextStore,
channel_id: str,
thread_ts: str,
payload: dict)
+
+
+
+ +Expand source code + +
class AsyncGetThreadContext:
+    thread_context_store: AsyncAssistantThreadContextStore
+    payload: dict
+    channel_id: str
+    thread_ts: str
+
+    _thread_context: Optional[AssistantThreadContext]
+    thread_context_loaded: bool
+
+    def __init__(
+        self,
+        thread_context_store: AsyncAssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+        payload: dict,
+    ):
+        self.thread_context_store = thread_context_store
+        self.payload = payload
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+        self._thread_context: Optional[AssistantThreadContext] = None
+        self.thread_context_loaded = False
+
+    async def __call__(self) -> Optional[AssistantThreadContext]:
+        if self.thread_context_loaded is True:
+            return self._thread_context
+
+        thread = self.payload.get("assistant_thread")
+        if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None:
+            # assistant_thread_started
+            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:
+            # message event
+            self._thread_context = await self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts)
+
+        return self._thread_context
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var payload : dict
+
+

The type of the None singleton.

+
+
var thread_context_loaded : bool
+
+

The type of the None singleton.

+
+
var thread_context_storeAsyncAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+class AsyncListener +
+
+
+ +Expand source code + +
class AsyncListener(metaclass=ABCMeta):
+    matchers: Sequence[AsyncListenerMatcher]
+    middleware: Sequence[AsyncMiddleware]
+    ack_function: Callable[..., Awaitable[BoltResponse]]
+    lazy_functions: Sequence[Callable[..., Awaitable[None]]]
+    auto_acknowledgement: bool
+    ack_timeout: int
+
+    async def async_matches(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+    ) -> bool:
+        is_matched: bool = False
+        for matcher in self.matchers:
+            is_matched = await matcher.async_matches(req, resp)
+            if not is_matched:
+                return is_matched
+        return is_matched
+
+    async def run_async_middleware(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+    ) -> Tuple[Optional[BoltResponse], bool]:
+        """Runs an async middleware.
+
+        Args:
+            req: The incoming request
+            resp: The current response
+
+        Returns:
+            A tuple of the processed response and a flag indicating termination
+        """
+        for m in self.middleware:
+            middleware_state = {"next_called": False}
+
+            async def _next():
+                middleware_state["next_called"] = True
+
+            resp = await m.async_process(req=req, resp=resp, next=_next)  # type: ignore[assignment]
+            if not middleware_state["next_called"]:
+                # next() was not called in this middleware
+                return (resp, True)
+        return (resp, False)
+
+    @abstractmethod
+    async def run_ack_function(self, *, request: AsyncBoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+        """Runs all the registered middleware and then run the listener function.
+
+        Args:
+            request: The incoming request
+            response: The current response
+
+        Returns:
+            The processed response
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Class variables

+
+
var ack_function : Callable[..., Awaitable[BoltResponse]]
+
+

The type of the None singleton.

+
+
var ack_timeout : int
+
+

The type of the None singleton.

+
+
var auto_acknowledgement : bool
+
+

The type of the None singleton.

+
+
var lazy_functions : Sequence[Callable[..., Awaitable[None]]]
+
+

The type of the None singleton.

+
+
var matchers : Sequence[AsyncListenerMatcher]
+
+

The type of the None singleton.

+
+
var middleware : Sequence[AsyncMiddleware]
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def async_matches(self,
*,
req: AsyncBoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
async def async_matches(
+    self,
+    *,
+    req: AsyncBoltRequest,
+    resp: BoltResponse,
+) -> bool:
+    is_matched: bool = False
+    for matcher in self.matchers:
+        is_matched = await matcher.async_matches(req, resp)
+        if not is_matched:
+            return is_matched
+    return is_matched
+
+
+
+
+async def run_ack_function(self,
*,
request: AsyncBoltRequest,
response: BoltResponse) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+async def run_ack_function(self, *, request: AsyncBoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+    """Runs all the registered middleware and then run the listener function.
+
+    Args:
+        request: The incoming request
+        response: The current response
+
+    Returns:
+        The processed response
+    """
+    raise NotImplementedError()
+
+

Runs all the registered middleware and then run the listener function.

+

Args

+
+
request
+
The incoming request
+
response
+
The current response
+
+

Returns

+

The processed response

+
+
+async def run_async_middleware(self,
*,
req: AsyncBoltRequest,
resp: BoltResponse) ‑> Tuple[BoltResponse | None, bool]
+
+
+
+ +Expand source code + +
async def run_async_middleware(
+    self,
+    *,
+    req: AsyncBoltRequest,
+    resp: BoltResponse,
+) -> Tuple[Optional[BoltResponse], bool]:
+    """Runs an async middleware.
+
+    Args:
+        req: The incoming request
+        resp: The current response
+
+    Returns:
+        A tuple of the processed response and a flag indicating termination
+    """
+    for m in self.middleware:
+        middleware_state = {"next_called": False}
+
+        async def _next():
+            middleware_state["next_called"] = True
+
+        resp = await m.async_process(req=req, resp=resp, next=_next)  # type: ignore[assignment]
+        if not middleware_state["next_called"]:
+            # next() was not called in this middleware
+            return (resp, True)
+    return (resp, False)
+
+

Runs an async middleware.

+

Args

+
+
req
+
The incoming request
+
resp
+
The current response
+
+

Returns

+

A tuple of the processed response and a flag indicating termination

+
+
+
+
+class AsyncRespond +(*,
response_url: str | None,
proxy: str | None = None,
ssl: ssl.SSLContext | None = None)
+
+
+
+ +Expand source code + +
class AsyncRespond:
+    response_url: Optional[str]
+    proxy: Optional[str]
+    ssl: Optional[SSLContext]
+
+    def __init__(
+        self,
+        *,
+        response_url: Optional[str],
+        proxy: Optional[str] = None,
+        ssl: Optional[SSLContext] = None,
+    ):
+        self.response_url = response_url
+        self.proxy = proxy
+        self.ssl = ssl
+
+    async def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        response_type: Optional[str] = None,
+        replace_original: Optional[bool] = None,
+        delete_original: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> WebhookResponse:
+        if self.response_url is not None:
+            client = AsyncWebhookClient(
+                url=self.response_url,
+                proxy=self.proxy,
+                ssl=self.ssl,
+            )
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                message = _build_message(
+                    text=text,  # type: ignore[arg-type]
+                    blocks=blocks,
+                    attachments=attachments,
+                    response_type=response_type,
+                    replace_original=replace_original,
+                    delete_original=delete_original,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    thread_ts=thread_ts,
+                    metadata=metadata,
+                )
+                return await client.send_dict(message)
+            elif isinstance(text_or_whole_response, dict):
+                whole_response: dict = text_or_whole_response
+                message = _build_message(**whole_response)
+                return await client.send_dict(message)
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text)})")
+        else:
+            raise ValueError("respond is unsupported here as there is no response_url")
+
+
+

Class variables

+
+
var proxy : str | None
+
+

The type of the None singleton.

+
+
var response_url : str | None
+
+

The type of the None singleton.

+
+
var ssl : ssl.SSLContext | None
+
+

The type of the None singleton.

+
+
+
+
+class AsyncSaveThreadContext +(thread_context_store: AsyncAssistantThreadContextStore,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class AsyncSaveThreadContext:
+    thread_context_store: AsyncAssistantThreadContextStore
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        thread_context_store: AsyncAssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.thread_context_store = thread_context_store
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    async def __call__(self, new_context: Dict[str, str]) -> None:
+        await self.thread_context_store.save(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            context=new_context,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var thread_context_storeAsyncAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+class AsyncSay +(client: slack_sdk.web.async_client.AsyncWebClient | None,
channel: str | None,
thread_ts: str | None = None,
build_metadata: Callable[[], Awaitable[Dict | slack_sdk.models.metadata.Metadata]] | None = None)
+
+
+
+ +Expand source code + +
class AsyncSay:
+    client: Optional[AsyncWebClient]
+    channel: Optional[str]
+    thread_ts: Optional[str]
+    build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]]
+
+    def __init__(
+        self,
+        client: Optional[AsyncWebClient],
+        channel: Optional[str],
+        thread_ts: Optional[str] = None,
+        build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]] = None,
+    ):
+        self.client = client
+        self.channel = channel
+        self.thread_ts = thread_ts
+        self.build_metadata = build_metadata
+
+    async def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[Dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[Dict, Attachment]]] = None,
+        channel: Optional[str] = None,
+        as_user: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        reply_broadcast: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        icon_emoji: Optional[str] = None,
+        icon_url: Optional[str] = None,
+        username: Optional[str] = None,
+        markdown_text: Optional[str] = None,
+        mrkdwn: Optional[bool] = None,
+        link_names: Optional[bool] = None,
+        parse: Optional[str] = None,  # none, full
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        **kwargs,
+    ) -> AsyncSlackResponse:
+        if _can_say(self, channel):
+            if metadata is None and self.build_metadata is not None:
+                metadata = await self.build_metadata()
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                return await self.client.chat_postMessage(  # type: ignore[union-attr]
+                    channel=channel or self.channel,  # type: ignore[arg-type]
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    as_user=as_user,
+                    thread_ts=thread_ts or self.thread_ts,
+                    reply_broadcast=reply_broadcast,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    icon_emoji=icon_emoji,
+                    icon_url=icon_url,
+                    username=username,
+                    markdown_text=markdown_text,
+                    mrkdwn=mrkdwn,
+                    link_names=link_names,
+                    parse=parse,
+                    metadata=metadata,
+                    **kwargs,
+                )
+            elif isinstance(text_or_whole_response, dict):
+                message: dict = create_copy(text_or_whole_response)
+                if "channel" not in message:
+                    message["channel"] = channel or self.channel
+                if "thread_ts" not in message:
+                    message["thread_ts"] = thread_ts or self.thread_ts
+                if "metadata" not in message:
+                    message["metadata"] = metadata
+                return await self.client.chat_postMessage(**message)  # type: ignore[union-attr]
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("say without channel_id here is unsupported")
+
+
+

Class variables

+
+
var build_metadata : Callable[[], Awaitable[Dict | slack_sdk.models.metadata.Metadata]] | None
+
+

The type of the None singleton.

+
+
var channel : str | None
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient | None
+
+

The type of the None singleton.

+
+
var thread_ts : str | None
+
+

The type of the None singleton.

+
+
+
+
+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)
+
+
+
+ +Expand source code + +
class AsyncSetStatus:
+    client: AsyncWebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            status=status,
+            loading_messages=loading_messages,
+            icon_emoji=icon_emoji,
+            icon_url=icon_url,
+            username=username,
+            **kwargs,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+class AsyncSetSuggestedPrompts +(client: slack_sdk.web.async_client.AsyncWebClient,
channel_id: str,
thread_ts: str | None = None)
+
+
+
+ +Expand source code + +
class AsyncSetSuggestedPrompts:
+    client: AsyncWebClient
+    channel_id: str
+    thread_ts: Optional[str]
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        channel_id: str,
+        thread_ts: Optional[str] = None,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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:
+            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=self.channel_id,
+            thread_ts=thread_ts if thread_ts is not None else self.thread_ts,
+            prompts=prompts_arg,
+            title=title,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str | None
+
+

The type of the None singleton.

+
+
+
+
+class AsyncSetTitle +(client: slack_sdk.web.async_client.AsyncWebClient,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class AsyncSetTitle:
+    client: AsyncWebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    async def __call__(self, title: str) -> AsyncSlackResponse:
+        return await self.client.assistant_threads_setTitle(
+            title=title,
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/authorization/async_authorize.html b/docs/reference/authorization/async_authorize.html new file mode 100644 index 000000000..b4dfa2682 --- /dev/null +++ b/docs/reference/authorization/async_authorize.html @@ -0,0 +1,524 @@ + + + + + + +slack_bolt.authorization.async_authorize API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.authorization.async_authorize

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAuthorize +
+
+
+ +Expand source code + +
class AsyncAuthorize:
+    """This provides authorize function that returns AuthorizeResult
+    for an incoming request from Slack."""
+
+    def __init__(self):
+        pass
+
+    async def __call__(
+        self,
+        *,
+        context: AsyncBoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+        # actor_* can be used only when user_token_resolution: "actor" is set
+        actor_enterprise_id: Optional[str] = None,
+        actor_team_id: Optional[str] = None,
+        actor_user_id: Optional[str] = None,
+    ) -> Optional[AuthorizeResult]:
+        raise NotImplementedError()
+
+

This provides authorize function that returns AuthorizeResult +for an incoming request from Slack.

+

Subclasses

+ +
+
+class AsyncCallableAuthorize +(*,
logger: logging.Logger,
func: Callable[..., Awaitable[AuthorizeResult]])
+
+
+
+ +Expand source code + +
class AsyncCallableAuthorize(AsyncAuthorize):
+    """When you pass the authorize argument in AsyncApp constructor,
+    This authorize implementation will be used.
+    """
+
+    def __init__(self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizeResult]]):
+        self.logger = logger
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+
+    async def __call__(
+        self,
+        *,
+        context: AsyncBoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+        # actor_* can be used only when user_token_resolution: "actor" is set
+        actor_enterprise_id: Optional[str] = None,
+        actor_team_id: Optional[str] = None,
+        actor_user_id: Optional[str] = None,
+    ) -> Optional[AuthorizeResult]:
+        try:
+            all_available_args = {
+                "args": AsyncAuthorizeArgs(
+                    context=context,
+                    enterprise_id=enterprise_id,
+                    team_id=team_id,
+                    user_id=user_id,
+                ),
+                "logger": context.logger,
+                "client": context.client,
+                "context": context,
+                "enterprise_id": enterprise_id,
+                "team_id": team_id,
+                "user_id": user_id,
+                "actor_enterprise_id": actor_enterprise_id,
+                "actor_team_id": actor_team_id,
+                "actor_user_id": actor_user_id,
+            }
+            for k, v in context.items():
+                if k not in all_available_args:
+                    all_available_args[k] = v
+
+            kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in self.arg_names}
+            found_arg_names = kwargs.keys()
+            for name in self.arg_names:
+                if name not in found_arg_names:
+                    self.logger.warning(f"{name} is not a valid argument")
+                    kwargs[name] = None
+
+            auth_result: Optional[AuthorizeResult] = await self.func(**kwargs)
+            if auth_result is None:
+                return auth_result
+
+            if isinstance(auth_result, AuthorizeResult):
+                return auth_result
+            else:
+                raise ValueError(f"Unexpected returned value from authorize function (type: {type(auth_result)})")
+        except SlackApiError as err:
+            self.logger.debug(
+                f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} "
+                f"is no longer valid. (response: {err.response})"
+            )
+            return None
+
+

When you pass the authorize argument in AsyncApp constructor, +This authorize implementation will be used.

+

Ancestors

+ +
+
+class AsyncInstallationStoreAuthorize +(*,
logger: logging.Logger,
installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore,
client_id: str | None = None,
client_secret: str | None = None,
token_rotation_expiration_minutes: int | None = None,
bot_only: bool = False,
cache_enabled: bool = False,
client: slack_sdk.web.async_client.AsyncWebClient | None = None,
user_token_resolution: str = 'authed_user')
+
+
+
+ +Expand source code + +
class AsyncInstallationStoreAuthorize(AsyncAuthorize):
+    """If you use the OAuth flow settings, this authorize implementation will be used.
+    As long as your own InstallationStore (or the built-in ones) works as you expect,
+    you can expect that the authorize layer should work for you without any customization.
+    """
+
+    authorize_result_cache: Dict[str, AuthorizeResult]
+    bot_only: bool
+    user_token_resolution: str
+    find_installation_available: Optional[bool]
+    find_bot_available: Optional[bool]
+    token_rotator: Optional[AsyncTokenRotator]
+
+    _config_error_message: str = "AsyncInstallationStore with client_id/client_secret are required for token rotation"
+
+    def __init__(
+        self,
+        *,
+        logger: Logger,
+        installation_store: AsyncInstallationStore,
+        client_id: Optional[str] = None,
+        client_secret: Optional[str] = None,
+        token_rotation_expiration_minutes: Optional[int] = None,
+        # For v1.0.x compatibility and people who still want its simplicity
+        # use only InstallationStore#find_bot(enterprise_id, team_id)
+        bot_only: bool = False,
+        cache_enabled: bool = False,
+        client: Optional[AsyncWebClient] = None,
+        # Since v1.27, user token resolution can be actor ID based when the mode is enabled
+        user_token_resolution: str = "authed_user",
+    ):
+        self.logger = logger
+        self.installation_store = installation_store
+        self.bot_only = bot_only
+        self.user_token_resolution = user_token_resolution
+        self.cache_enabled = cache_enabled
+        self.authorize_result_cache = {}
+        self.find_installation_available = None
+        self.find_bot_available = None
+        if client_id is not None and client_secret is not None:
+            self.token_rotator = AsyncTokenRotator(
+                client_id=client_id,
+                client_secret=client_secret,
+                client=client,
+            )
+        else:
+            self.token_rotator = None
+        self.token_rotation_expiration_minutes = token_rotation_expiration_minutes or 120
+
+    async def __call__(
+        self,
+        *,
+        context: AsyncBoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+        # actor_* can be used only when user_token_resolution: "actor" is set
+        actor_enterprise_id: Optional[str] = None,
+        actor_team_id: Optional[str] = None,
+        actor_user_id: Optional[str] = None,
+    ) -> Optional[AuthorizeResult]:
+
+        if self.find_installation_available is None:
+            self.find_installation_available = hasattr(self.installation_store, "async_find_installation")
+        if self.find_bot_available is None:
+            self.find_bot_available = hasattr(self.installation_store, "async_find_bot")
+
+        bot_token: Optional[str] = None
+        user_token: Optional[str] = None
+        bot_scopes: Optional[Sequence[str]] = None
+        user_scopes: Optional[Sequence[str]] = None
+        latest_bot_installation: Optional[Installation] = None
+        this_user_installation: Optional[Installation] = None
+
+        if not self.bot_only and self.find_installation_available:
+            # Since v1.1, this is the default way.
+            # If you want to use find_bot / delete_bot only, you can set bot_only as True.
+            try:
+                # Note that this is the latest information for the org/workspace.
+                # The installer may not be the user associated with this incoming request.
+                latest_bot_installation = await self.installation_store.async_find_installation(
+                    enterprise_id=enterprise_id,
+                    team_id=team_id,
+                    is_enterprise_install=context.is_enterprise_install,
+                )
+                # If the user_token in the latest_installation is not for the user associated with this request,
+                # we'll fetch a different installation for the user below
+                # The example use cases are:
+                # - The app's installation requires both bot and user tokens
+                # - The app has two installation paths 1) bot installation 2) individual user authorization
+                if latest_bot_installation is not None:
+                    # Save the latest bot token
+                    bot_token = latest_bot_installation.bot_token  # this still can be None
+                    user_token = latest_bot_installation.user_token  # this still can be None
+                    bot_scopes = latest_bot_installation.bot_scopes  # this still can be None
+                    user_scopes = latest_bot_installation.user_scopes  # this still can be None
+
+                    if latest_bot_installation.user_id != user_id:
+                        # First off, remove the user token as the installer is a different user
+                        user_token = None
+                        user_scopes = None
+                        latest_bot_installation.user_token = None
+                        latest_bot_installation.user_refresh_token = None
+                        latest_bot_installation.user_token_expires_at = None
+                        latest_bot_installation.user_scopes = None
+
+                        # try to fetch the request user's installation
+                        # to reflect the user's access token if exists
+                        # try to fetch the request user's installation
+                        # to reflect the user's access token if exists
+                        if self.user_token_resolution == "actor":
+                            if actor_enterprise_id is not None or actor_team_id is not None:
+                                # Note that actor_team_id can be absent for app_mention events
+                                this_user_installation = await self.installation_store.async_find_installation(
+                                    enterprise_id=actor_enterprise_id,
+                                    team_id=actor_team_id,
+                                    user_id=actor_user_id,
+                                    is_enterprise_install=None,
+                                )
+                        else:
+                            this_user_installation = await self.installation_store.async_find_installation(
+                                enterprise_id=enterprise_id,
+                                team_id=team_id,
+                                user_id=user_id,
+                                is_enterprise_install=context.is_enterprise_install,
+                            )
+                        if this_user_installation is not None:
+                            user_token = this_user_installation.user_token
+                            user_scopes = this_user_installation.user_scopes
+                            if (
+                                latest_bot_installation.bot_token is None
+                                # enterprise_id/team_id can be different for Slack Connect channel events
+                                # when enabling user_token_resolution: "actor"
+                                and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id
+                                and latest_bot_installation.team_id == this_user_installation.team_id
+                            ):
+                                # If latest_installation has a bot token, we never overwrite the value
+                                bot_token = this_user_installation.bot_token
+                                bot_scopes = this_user_installation.bot_scopes
+
+                            # If token rotation is enabled, running rotation may be needed here
+                            refreshed = await self._rotate_and_save_tokens_if_necessary(this_user_installation)
+                            if refreshed is not None:
+                                user_token = refreshed.user_token
+                                user_scopes = refreshed.user_scopes
+                                if (
+                                    latest_bot_installation.bot_token is None
+                                    # enterprise_id/team_id can be different for Slack Connect channel events
+                                    # when enabling user_token_resolution: "actor"
+                                    and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id
+                                    and latest_bot_installation.team_id == this_user_installation.team_id
+                                ):
+                                    # If latest_installation has a bot token, we never overwrite the value
+                                    bot_token = refreshed.bot_token
+                                    bot_scopes = refreshed.bot_scopes
+
+                    # If token rotation is enabled, running rotation may be needed here
+                    refreshed = await self._rotate_and_save_tokens_if_necessary(latest_bot_installation)
+                    if refreshed is not None:
+                        bot_token = refreshed.bot_token
+                        bot_scopes = refreshed.bot_scopes
+                        if this_user_installation is None:
+                            # Only when we don't have `this_user_installation` here,
+                            # the `user_token` is for the user associated with this request
+                            user_token = refreshed.user_token
+                            user_scopes = refreshed.user_scopes
+
+            except SlackTokenRotationError as rotation_error:
+                # When token rotation fails, it is usually unrecoverable
+                # So, this built-in middleware gives up continuing with the following middleware and listeners
+                self.logger.error(f"Failed to rotate tokens due to {rotation_error}")
+                return None
+            except NotImplementedError as _:
+                self.find_installation_available = False
+
+        if (
+            # If you intentionally use only `find_bot` / `delete_bot`,
+            self.bot_only
+            # If the `find_installation` method is not available,
+            or not self.find_installation_available
+            # If the `find_installation` method did not return data and find_bot method is available,
+            or (self.find_bot_available is True and bot_token is None and user_token is None)
+        ):
+            try:
+                bot: Optional[Bot] = await self.installation_store.async_find_bot(
+                    enterprise_id=enterprise_id,
+                    team_id=team_id,
+                    is_enterprise_install=context.is_enterprise_install,
+                )
+                if bot is not None:
+                    bot_token = bot.bot_token
+                    bot_scopes = bot.bot_scopes
+                    if bot.bot_refresh_token is not None:
+                        # Token rotation
+                        if self.token_rotator is None:
+                            raise BoltError(self._config_error_message)
+                        refreshed_bot = await self.token_rotator.perform_bot_token_rotation(
+                            bot=bot,
+                            minutes_before_expiration=self.token_rotation_expiration_minutes,
+                        )
+                        if refreshed_bot is not None:
+                            await self.installation_store.async_save_bot(refreshed_bot)
+                            bot_token = refreshed_bot.bot_token
+                            bot_scopes = refreshed_bot.bot_scopes
+
+            except SlackTokenRotationError as rotation_error:
+                # When token rotation fails, it is usually unrecoverable
+                # So, this built-in middleware gives up continuing with the following middleware and listeners
+                self.logger.error(f"Failed to rotate tokens due to {rotation_error}")
+                return None
+            except NotImplementedError as _:
+                self.find_bot_available = False
+            except Exception as e:
+                self.logger.info(f"Failed to call find_bot method: {e}")
+
+        token: Optional[str] = bot_token or user_token
+        if token is None:
+            # No valid token was found
+            self._debug_log_for_not_found(enterprise_id, team_id)
+            return None
+
+        # Check cache to see if the bot object already exists
+        if self.cache_enabled and token in self.authorize_result_cache:
+            return self.authorize_result_cache[token]
+
+        try:
+            auth_test_api_response = await context.client.auth_test(token=token)
+            user_auth_test_response = None
+            if user_token is not None and token != user_token:
+                user_auth_test_response = await context.client.auth_test(token=user_token)
+            authorize_result = AuthorizeResult.from_auth_test_response(
+                auth_test_response=auth_test_api_response,
+                user_auth_test_response=user_auth_test_response,
+                bot_token=bot_token,
+                user_token=user_token,
+                bot_scopes=bot_scopes,
+                user_scopes=user_scopes,
+            )
+            if self.cache_enabled:
+                self.authorize_result_cache[token] = authorize_result
+            return authorize_result
+        except SlackApiError as err:
+            self.logger.debug(
+                f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} "
+                f"is no longer valid. (response: {err.response})"
+            )
+            return None
+
+    # ------------------------------------------------
+
+    def _debug_log_for_not_found(self, enterprise_id: Optional[str], team_id: Optional[str]):
+        self.logger.debug("No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}")
+
+    async def _rotate_and_save_tokens_if_necessary(self, installation: Optional[Installation]) -> Optional[Installation]:
+        if installation is None or (installation.user_refresh_token is None and installation.bot_refresh_token is None):
+            # No need to rotate tokens
+            return None
+
+        if self.token_rotator is None:
+            # Token rotation is required but this Bolt app is not properly configured
+            raise BoltError(self._config_error_message)
+
+        refreshed: Optional[Installation] = await self.token_rotator.perform_token_rotation(
+            installation=installation,
+            minutes_before_expiration=self.token_rotation_expiration_minutes,
+        )
+        if refreshed is not None:
+            # Save the refreshed data in database for following requests
+            await self.installation_store.async_save(refreshed)
+        return refreshed
+
+

If you use the OAuth flow settings, this authorize implementation will be used. +As long as your own InstallationStore (or the built-in ones) works as you expect, +you can expect that the authorize layer should work for you without any customization.

+

Ancestors

+ +

Class variables

+
+
var authorize_result_cache : Dict[str, AuthorizeResult]
+
+

The type of the None singleton.

+
+
var bot_only : bool
+
+

The type of the None singleton.

+
+
var find_bot_available : bool | None
+
+

The type of the None singleton.

+
+
var find_installation_available : bool | None
+
+

The type of the None singleton.

+
+
var token_rotator : slack_sdk.oauth.token_rotation.async_rotator.AsyncTokenRotator | None
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/authorization/async_authorize_args.html b/docs/reference/authorization/async_authorize_args.html new file mode 100644 index 000000000..5de20f757 --- /dev/null +++ b/docs/reference/authorization/async_authorize_args.html @@ -0,0 +1,164 @@ + + + + + + +slack_bolt.authorization.async_authorize_args API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.authorization.async_authorize_args

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAuthorizeArgs +(*,
context: AsyncBoltContext,
enterprise_id: str | None,
team_id: str | None,
user_id: str | None)
+
+
+
+ +Expand source code + +
class AsyncAuthorizeArgs:
+    context: AsyncBoltContext
+    logger: Logger
+    client: AsyncWebClient
+    enterprise_id: Optional[str]
+    team_id: Optional[str]
+    user_id: Optional[str]
+
+    def __init__(
+        self,
+        *,
+        context: AsyncBoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+    ):
+        """The full list of the arguments passed to `authorize` function.
+
+        Args:
+            context: The request context
+            enterprise_id: The Organization ID (Enterprise Grid)
+            team_id: The workspace ID
+            user_id: The request user ID
+        """
+        self.context = context
+        self.logger = context.logger
+        self.client = context.client
+        self.enterprise_id = enterprise_id
+        self.team_id = team_id
+        self.user_id = user_id
+
+

The full list of the arguments passed to authorize function.

+

Args

+
+
context
+
The request context
+
enterprise_id
+
The Organization ID (Enterprise Grid)
+
team_id
+
The workspace ID
+
user_id
+
The request user ID
+
+

Class variables

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var contextAsyncBoltContext
+
+

The type of the None singleton.

+
+
var enterprise_id : str | None
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var team_id : str | None
+
+

The type of the None singleton.

+
+
var user_id : str | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/authorization/authorize.html b/docs/reference/authorization/authorize.html new file mode 100644 index 000000000..33b50be02 --- /dev/null +++ b/docs/reference/authorization/authorize.html @@ -0,0 +1,522 @@ + + + + + + +slack_bolt.authorization.authorize API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.authorization.authorize

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Authorize +
+
+
+ +Expand source code + +
class Authorize:
+    """This provides authorize function that returns AuthorizeResult
+    for an incoming request from Slack."""
+
+    def __init__(self):
+        pass
+
+    def __call__(
+        self,
+        *,
+        context: BoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+        # actor_* can be used only when user_token_resolution: "actor" is set
+        actor_enterprise_id: Optional[str] = None,
+        actor_team_id: Optional[str] = None,
+        actor_user_id: Optional[str] = None,
+    ) -> Optional[AuthorizeResult]:
+        raise NotImplementedError()
+
+

This provides authorize function that returns AuthorizeResult +for an incoming request from Slack.

+

Subclasses

+ +
+
+class CallableAuthorize +(*,
logger: logging.Logger,
func: Callable[..., AuthorizeResult])
+
+
+
+ +Expand source code + +
class CallableAuthorize(Authorize):
+    """When you pass the `authorize` argument in AsyncApp constructor,
+    This `authorize` implementation will be used.
+    """
+
+    def __init__(
+        self,
+        *,
+        logger: Logger,
+        func: Callable[..., AuthorizeResult],
+    ):
+        self.logger = logger
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+
+    def __call__(
+        self,
+        *,
+        context: BoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+        # actor_* can be used only when user_token_resolution: "actor" is set
+        actor_enterprise_id: Optional[str] = None,
+        actor_team_id: Optional[str] = None,
+        actor_user_id: Optional[str] = None,
+    ) -> Optional[AuthorizeResult]:
+        try:
+            all_available_args = {
+                "args": AuthorizeArgs(
+                    context=context,
+                    enterprise_id=enterprise_id,
+                    team_id=team_id,
+                    user_id=user_id,
+                ),
+                "logger": context.logger,
+                "client": context.client,
+                "context": context,
+                "enterprise_id": enterprise_id,
+                "team_id": team_id,
+                "user_id": user_id,
+                "actor_enterprise_id": actor_enterprise_id,
+                "actor_team_id": actor_team_id,
+                "actor_user_id": actor_user_id,
+            }
+            for k, v in context.items():
+                if k not in all_available_args:
+                    all_available_args[k] = v
+
+            kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in self.arg_names}
+            found_arg_names = kwargs.keys()
+            for name in self.arg_names:
+                if name not in found_arg_names:
+                    self.logger.warning(f"{name} is not a valid argument")
+                    kwargs[name] = None
+
+            auth_result = self.func(**kwargs)
+            if auth_result is None:
+                return auth_result
+
+            if isinstance(auth_result, AuthorizeResult):
+                return auth_result
+            else:
+                raise ValueError(f"Unexpected returned value from authorize function (type: {type(auth_result)})")
+        except SlackApiError as err:
+            self.logger.debug(
+                f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} "
+                f"is no longer valid. (response: {err.response})"
+            )
+            return None
+
+

When you pass the authorize argument in AsyncApp constructor, +This authorize implementation will be used.

+

Ancestors

+ +
+
+class InstallationStoreAuthorize +(*,
logger: logging.Logger,
installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore,
client_id: str | None = None,
client_secret: str | None = None,
token_rotation_expiration_minutes: int | None = None,
bot_only: bool = False,
cache_enabled: bool = False,
client: slack_sdk.web.client.WebClient | None = None,
user_token_resolution: str = 'authed_user')
+
+
+
+ +Expand source code + +
class InstallationStoreAuthorize(Authorize):
+    """If you use the OAuth flow settings, this `authorize` implementation will be used.
+    As long as your own InstallationStore (or the built-in ones) works as you expect,
+    you can expect that the `authorize` layer should work for you without any customization.
+    """
+
+    authorize_result_cache: Dict[str, AuthorizeResult]
+    bot_only: bool
+    user_token_resolution: str
+    find_installation_available: bool
+    find_bot_available: bool
+    token_rotator: Optional[TokenRotator]
+
+    _config_error_message: str = "InstallationStore with client_id/client_secret are required for token rotation"
+
+    def __init__(
+        self,
+        *,
+        logger: Logger,
+        installation_store: InstallationStore,
+        client_id: Optional[str] = None,
+        client_secret: Optional[str] = None,
+        token_rotation_expiration_minutes: Optional[int] = None,
+        # For v1.0.x compatibility and people who still want its simplicity
+        # use only InstallationStore#find_bot(enterprise_id, team_id)
+        bot_only: bool = False,
+        cache_enabled: bool = False,
+        client: Optional[WebClient] = None,
+        # Since v1.27, user token resolution can be actor ID based when the mode is enabled
+        user_token_resolution: str = "authed_user",
+    ):
+        self.logger = logger
+        self.installation_store = installation_store
+        self.bot_only = bot_only
+        self.user_token_resolution = user_token_resolution
+        self.cache_enabled = cache_enabled
+        self.authorize_result_cache = {}
+        self.find_installation_available = hasattr(installation_store, "find_installation")
+        self.find_bot_available = hasattr(installation_store, "find_bot")
+        if client_id is not None and client_secret is not None:
+            self.token_rotator = TokenRotator(
+                client_id=client_id,
+                client_secret=client_secret,
+                client=client,
+            )
+        else:
+            self.token_rotator = None
+        self.token_rotation_expiration_minutes = token_rotation_expiration_minutes or 120
+
+    def __call__(
+        self,
+        *,
+        context: BoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+        # actor_* can be used only when user_token_resolution: "actor" is set
+        actor_enterprise_id: Optional[str] = None,
+        actor_team_id: Optional[str] = None,
+        actor_user_id: Optional[str] = None,
+    ) -> Optional[AuthorizeResult]:
+
+        bot_token: Optional[str] = None
+        user_token: Optional[str] = None
+        bot_scopes: Optional[Sequence[str]] = None
+        user_scopes: Optional[Sequence[str]] = None
+        latest_bot_installation: Optional[Installation] = None
+        this_user_installation: Optional[Installation] = None
+
+        if not self.bot_only and self.find_installation_available:
+            # Since v1.1, this is the default way.
+            # If you want to use find_bot / delete_bot only, you can set bot_only as True.
+            try:
+                # Note that this is the latest information for the org/workspace.
+                # The installer may not be the user associated with this incoming request.
+                latest_bot_installation = self.installation_store.find_installation(
+                    enterprise_id=enterprise_id,
+                    team_id=team_id,
+                    is_enterprise_install=context.is_enterprise_install,
+                )
+                # If the user_token in the latest_installation is not for the user associated with this request,
+                # we'll fetch a different installation for the user below.
+                # The example use cases are:
+                # - The app's installation requires both bot and user tokens
+                # - The app has two installation paths 1) bot installation 2) individual user authorization
+                if latest_bot_installation is not None:
+                    # Save the latest bot token
+                    bot_token = latest_bot_installation.bot_token  # this still can be None
+                    user_token = latest_bot_installation.user_token  # this still can be None
+                    bot_scopes = latest_bot_installation.bot_scopes  # this still can be None
+                    user_scopes = latest_bot_installation.user_scopes  # this still can be None
+
+                    if latest_bot_installation.user_id != user_id:
+                        # First off, remove the user token as the installer is a different user
+                        user_token = None
+                        user_scopes = None
+                        latest_bot_installation.user_token = None
+                        latest_bot_installation.user_refresh_token = None
+                        latest_bot_installation.user_token_expires_at = None
+                        latest_bot_installation.user_scopes = None
+
+                        # try to fetch the request user's installation
+                        # to reflect the user's access token if exists
+                        if self.user_token_resolution == "actor":
+                            if actor_enterprise_id is not None or actor_team_id is not None:
+                                # Note that actor_team_id can be absent for app_mention events
+                                this_user_installation = self.installation_store.find_installation(
+                                    enterprise_id=actor_enterprise_id,
+                                    team_id=actor_team_id,
+                                    user_id=actor_user_id,
+                                    is_enterprise_install=None,
+                                )
+                        else:
+                            this_user_installation = self.installation_store.find_installation(
+                                enterprise_id=enterprise_id,
+                                team_id=team_id,
+                                user_id=user_id,
+                                is_enterprise_install=context.is_enterprise_install,
+                            )
+                        if this_user_installation is not None:
+                            user_token = this_user_installation.user_token
+                            user_scopes = this_user_installation.user_scopes
+                            if (
+                                latest_bot_installation.bot_token is None
+                                # enterprise_id/team_id can be different for Slack Connect channel events
+                                # when enabling user_token_resolution: "actor"
+                                and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id
+                                and latest_bot_installation.team_id == this_user_installation.team_id
+                            ):
+                                # If latest_installation has a bot token, we never overwrite the value
+                                bot_token = this_user_installation.bot_token
+                                bot_scopes = this_user_installation.bot_scopes
+
+                            # If token rotation is enabled, running rotation may be needed here
+                            refreshed = self._rotate_and_save_tokens_if_necessary(this_user_installation)
+                            if refreshed is not None:
+                                user_token = refreshed.user_token
+                                user_scopes = refreshed.user_scopes
+                                if (
+                                    latest_bot_installation.bot_token is None
+                                    # enterprise_id/team_id can be different for Slack Connect channel events
+                                    # when enabling user_token_resolution: "actor"
+                                    and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id
+                                    and latest_bot_installation.team_id == this_user_installation.team_id
+                                ):
+                                    # If latest_installation has a bot token, we never overwrite the value
+                                    bot_token = refreshed.bot_token
+                                    bot_scopes = refreshed.bot_scopes
+
+                    # If token rotation is enabled, running rotation may be needed here
+                    refreshed = self._rotate_and_save_tokens_if_necessary(latest_bot_installation)
+                    if refreshed is not None:
+                        bot_token = refreshed.bot_token
+                        bot_scopes = refreshed.bot_scopes
+                        if this_user_installation is None:
+                            # Only when we don't have `this_user_installation` here,
+                            # the `user_token` is for the user associated with this request
+                            user_token = refreshed.user_token
+                            user_scopes = refreshed.user_scopes
+
+            except SlackTokenRotationError as rotation_error:
+                # When token rotation fails, it is usually unrecoverable
+                # So, this built-in middleware gives up continuing with the following middleware and listeners
+                self.logger.error(f"Failed to rotate tokens due to {rotation_error}")
+                return None
+            except NotImplementedError as _:
+                self.find_installation_available = False
+
+        if (
+            # If you intentionally use only `find_bot` / `delete_bot`,
+            self.bot_only
+            # If the `find_installation` method is not available,
+            or not self.find_installation_available
+            # If the `find_installation` method did not return data and find_bot method is available,
+            or (self.find_bot_available is True and bot_token is None and user_token is None)
+        ):
+            try:
+                bot: Optional[Bot] = self.installation_store.find_bot(
+                    enterprise_id=enterprise_id,
+                    team_id=team_id,
+                    is_enterprise_install=context.is_enterprise_install,
+                )
+                if bot is not None:
+                    bot_token = bot.bot_token
+                    bot_scopes = bot.bot_scopes
+                    if bot.bot_refresh_token is not None:
+                        # Token rotation
+                        if self.token_rotator is None:
+                            raise BoltError(self._config_error_message)
+                        refreshed_bot = self.token_rotator.perform_bot_token_rotation(
+                            bot=bot,
+                            minutes_before_expiration=self.token_rotation_expiration_minutes,
+                        )
+                        if refreshed_bot is not None:
+                            self.installation_store.save_bot(refreshed_bot)
+                            bot_token = refreshed_bot.bot_token
+                            bot_scopes = refreshed_bot.bot_scopes
+
+            except SlackTokenRotationError as rotation_error:
+                # When token rotation fails, it is usually unrecoverable
+                # So, this built-in middleware gives up continuing with the following middleware and listeners
+                self.logger.error(f"Failed to rotate tokens due to {rotation_error}")
+                return None
+            except NotImplementedError as _:
+                self.find_bot_available = False
+            except Exception as e:
+                self.logger.info(f"Failed to call find_bot method: {e}")
+
+        token: Optional[str] = bot_token or user_token
+        if token is None:
+            # No valid token was found
+            self._debug_log_for_not_found(enterprise_id, team_id)
+            return None
+
+        # Check cache to see if the bot object already exists
+        if self.cache_enabled and token in self.authorize_result_cache:
+            return self.authorize_result_cache[token]
+
+        try:
+            auth_test_api_response = context.client.auth_test(token=token)
+            user_auth_test_response = None
+            if user_token is not None and token != user_token:
+                user_auth_test_response = context.client.auth_test(token=user_token)
+            authorize_result = AuthorizeResult.from_auth_test_response(
+                auth_test_response=auth_test_api_response,
+                user_auth_test_response=user_auth_test_response,
+                bot_token=bot_token,
+                user_token=user_token,
+                bot_scopes=bot_scopes,
+                user_scopes=user_scopes,
+            )
+            if self.cache_enabled:
+                self.authorize_result_cache[token] = authorize_result
+            return authorize_result
+        except SlackApiError as err:
+            self.logger.debug(
+                f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} "
+                f"is no longer valid. (response: {err.response})"
+            )
+            return None
+
+    # ------------------------------------------------
+
+    def _debug_log_for_not_found(self, enterprise_id: Optional[str], team_id: Optional[str]):
+        self.logger.debug("No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}")
+
+    def _rotate_and_save_tokens_if_necessary(self, installation: Optional[Installation]) -> Optional[Installation]:
+        if installation is None or (installation.user_refresh_token is None and installation.bot_refresh_token is None):
+            # No need to rotate tokens
+            return None
+
+        if self.token_rotator is None:
+            # Token rotation is required but this Bolt app is not properly configured
+            raise BoltError(self._config_error_message)
+
+        refreshed: Optional[Installation] = self.token_rotator.perform_token_rotation(
+            installation=installation,
+            minutes_before_expiration=self.token_rotation_expiration_minutes,
+        )
+        if refreshed is not None:
+            # Save the refreshed data in database for following requests
+            self.installation_store.save(refreshed)
+        return refreshed
+
+

If you use the OAuth flow settings, this authorize implementation will be used. +As long as your own InstallationStore (or the built-in ones) works as you expect, +you can expect that the authorize layer should work for you without any customization.

+

Ancestors

+ +

Class variables

+
+
var authorize_result_cache : Dict[str, AuthorizeResult]
+
+

The type of the None singleton.

+
+
var bot_only : bool
+
+

The type of the None singleton.

+
+
var find_bot_available : bool
+
+

The type of the None singleton.

+
+
var find_installation_available : bool
+
+

The type of the None singleton.

+
+
var token_rotator : slack_sdk.oauth.token_rotation.rotator.TokenRotator | None
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/authorization/authorize_args.html b/docs/reference/authorization/authorize_args.html new file mode 100644 index 000000000..78423fc40 --- /dev/null +++ b/docs/reference/authorization/authorize_args.html @@ -0,0 +1,164 @@ + + + + + + +slack_bolt.authorization.authorize_args API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.authorization.authorize_args

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AuthorizeArgs +(*,
context: BoltContext,
enterprise_id: str | None,
team_id: str | None,
user_id: str | None)
+
+
+
+ +Expand source code + +
class AuthorizeArgs:
+    context: BoltContext
+    logger: Logger
+    client: WebClient
+    enterprise_id: Optional[str]
+    team_id: Optional[str]
+    user_id: Optional[str]
+
+    def __init__(
+        self,
+        *,
+        context: BoltContext,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],  # can be None for org-wide installed apps
+        user_id: Optional[str],
+    ):
+        """The full list of the arguments passed to `authorize` function.
+
+        Args:
+            context: The request context
+            enterprise_id: The Organization ID (Enterprise Grid)
+            team_id: The workspace ID
+            user_id: The request user ID
+        """
+        self.context = context
+        self.logger = context.logger
+        self.client = context.client
+        self.enterprise_id = enterprise_id
+        self.team_id = team_id
+        self.user_id = user_id
+
+

The full list of the arguments passed to authorize function.

+

Args

+
+
context
+
The request context
+
enterprise_id
+
The Organization ID (Enterprise Grid)
+
team_id
+
The workspace ID
+
user_id
+
The request user ID
+
+

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var contextBoltContext
+
+

The type of the None singleton.

+
+
var enterprise_id : str | None
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var team_id : str | None
+
+

The type of the None singleton.

+
+
var user_id : str | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/authorization/authorize_result.html b/docs/reference/authorization/authorize_result.html new file mode 100644 index 000000000..d53c5cd5c --- /dev/null +++ b/docs/reference/authorization/authorize_result.html @@ -0,0 +1,298 @@ + + + + + + +slack_bolt.authorization.authorize_result API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.authorization.authorize_result

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AuthorizeResult +(*,
enterprise_id: str | None,
team_id: str | None,
team: str | None = None,
url: str | None = None,
bot_user_id: str | None = None,
bot_id: str | None = None,
bot_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_id: str | None = None,
user: str | None = None,
user_token: str | None = None,
user_scopes: Sequence[str] | str | None = None)
+
+
+
+ +Expand source code + +
class AuthorizeResult(dict):
+    """Authorize function call result"""
+
+    enterprise_id: Optional[str]
+    team_id: Optional[str]
+    team: Optional[str]  # since v1.18
+    url: Optional[str]  # since v1.18
+
+    bot_id: Optional[str]
+    bot_user_id: Optional[str]
+    bot_token: Optional[str]
+    bot_scopes: Optional[Sequence[str]]  # since v1.17
+
+    user_id: Optional[str]
+    user: Optional[str]  # since v1.18
+    user_token: Optional[str]
+    user_scopes: Optional[Sequence[str]]  # since v1.17
+
+    def __init__(
+        self,
+        *,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],
+        team: Optional[str] = None,
+        url: Optional[str] = None,
+        # bot
+        bot_user_id: Optional[str] = None,
+        bot_id: Optional[str] = None,
+        bot_token: Optional[str] = None,
+        bot_scopes: Optional[Union[Sequence[str], str]] = None,
+        # user
+        user_id: Optional[str] = None,
+        user: Optional[str] = None,
+        user_token: Optional[str] = None,
+        user_scopes: Optional[Union[Sequence[str], str]] = None,
+    ):
+        """
+        Args:
+            enterprise_id: Organization ID (Enterprise Grid) starting with `E`
+            team_id: Workspace ID starting with `T`
+            team: Workspace name
+            url: Workspace slack.com URL
+            bot_user_id: Bot user's User ID starting with either `U` or `W`
+            bot_id: Bot ID starting with `B`
+            bot_token: Bot user access token starting with `xoxb-`
+            bot_scopes: The scopes associated with the bot token
+            user_id: The request user ID
+            user: The request user's name
+            user_token: User access token starting with `xoxp-`
+            user_scopes: The scopes associated wth the user token
+        """
+        self["enterprise_id"] = self.enterprise_id = enterprise_id
+        self["team_id"] = self.team_id = team_id
+        self["team"] = self.team = team
+        self["url"] = self.url = url
+        # bot
+        self["bot_user_id"] = self.bot_user_id = bot_user_id
+        self["bot_id"] = self.bot_id = bot_id
+        self["bot_token"] = self.bot_token = bot_token
+        if bot_scopes is not None and isinstance(bot_scopes, str):
+            bot_scopes = [scope.strip() for scope in bot_scopes.split(",")]
+        self["bot_scopes"] = self.bot_scopes = bot_scopes
+        # user
+        self["user_id"] = self.user_id = user_id
+        self["user"] = self.user = user
+        self["user_token"] = self.user_token = user_token
+        if user_scopes is not None and isinstance(user_scopes, str):
+            user_scopes = [scope.strip() for scope in user_scopes.split(",")]
+        self["user_scopes"] = self.user_scopes = user_scopes
+
+    @classmethod
+    def from_auth_test_response(
+        cls,
+        *,
+        bot_token: Optional[str] = None,
+        user_token: Optional[str] = None,
+        bot_scopes: Optional[Union[Sequence[str], str]] = None,
+        user_scopes: Optional[Union[Sequence[str], str]] = None,
+        auth_test_response: Union[SlackResponse, "AsyncSlackResponse"],  # type: ignore[name-defined]
+        user_auth_test_response: Optional[Union[SlackResponse, "AsyncSlackResponse"]] = None,  # type: ignore[name-defined]
+    ) -> "AuthorizeResult":
+        bot_user_id: Optional[str] = (
+            auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None
+        )
+        user_id: Optional[str] = auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None
+        user_name: Optional[str] = auth_test_response.get("user")
+        if user_id is None and user_auth_test_response is not None:
+            user_id = user_auth_test_response.get("user_id")
+            user_name = user_auth_test_response.get("user")
+
+        return AuthorizeResult(
+            enterprise_id=auth_test_response.get("enterprise_id"),
+            team_id=auth_test_response.get("team_id"),
+            team=auth_test_response.get("team"),
+            url=auth_test_response.get("url"),
+            bot_id=auth_test_response.get("bot_id"),
+            bot_user_id=bot_user_id,
+            bot_scopes=bot_scopes,
+            user_id=user_id,
+            user=user_name,
+            bot_token=bot_token,
+            user_token=user_token,
+            user_scopes=user_scopes,
+        )
+
+

Authorize function call result

+

Args

+
+
enterprise_id
+
Organization ID (Enterprise Grid) starting with E
+
team_id
+
Workspace ID starting with T
+
team
+
Workspace name
+
url
+
Workspace slack.com URL
+
bot_user_id
+
Bot user's User ID starting with either U or W
+
bot_id
+
Bot ID starting with B
+
bot_token
+
Bot user access token starting with xoxb-
+
bot_scopes
+
The scopes associated with the bot token
+
user_id
+
The request user ID
+
user
+
The request user's name
+
user_token
+
User access token starting with xoxp-
+
user_scopes
+
The scopes associated wth the user token
+
+

Ancestors

+
    +
  • builtins.dict
  • +
+

Class variables

+
+
var bot_id : str | None
+
+

The type of the None singleton.

+
+
var bot_scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+
var bot_token : str | None
+
+

The type of the None singleton.

+
+
var bot_user_id : str | None
+
+

The type of the None singleton.

+
+
var enterprise_id : str | None
+
+

The type of the None singleton.

+
+
var team : str | None
+
+

The type of the None singleton.

+
+
var team_id : str | None
+
+

The type of the None singleton.

+
+
var url : str | None
+
+

The type of the None singleton.

+
+
var user : str | None
+
+

The type of the None singleton.

+
+
var user_id : str | None
+
+

The type of the None singleton.

+
+
var user_scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+
var user_token : str | None
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def from_auth_test_response(*,
bot_token: str | None = None,
user_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_scopes: Sequence[str] | str | None = None,
auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/authorization/index.html b/docs/reference/authorization/index.html new file mode 100644 index 000000000..2fdd1f916 --- /dev/null +++ b/docs/reference/authorization/index.html @@ -0,0 +1,334 @@ + + + + + + +slack_bolt.authorization API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.authorization

+
+
+

Authorization is the process of determining which Slack credentials should be available +while processing an incoming Slack event.

+

Refer to https://docs.slack.dev/tools/bolt-python/concepts/authorization for details.

+
+
+

Sub-modules

+
+
slack_bolt.authorization.async_authorize
+
+
+
+
slack_bolt.authorization.async_authorize_args
+
+
+
+
slack_bolt.authorization.authorize
+
+
+
+
slack_bolt.authorization.authorize_args
+
+
+
+
slack_bolt.authorization.authorize_result
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AuthorizeResult +(*,
enterprise_id: str | None,
team_id: str | None,
team: str | None = None,
url: str | None = None,
bot_user_id: str | None = None,
bot_id: str | None = None,
bot_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_id: str | None = None,
user: str | None = None,
user_token: str | None = None,
user_scopes: Sequence[str] | str | None = None)
+
+
+
+ +Expand source code + +
class AuthorizeResult(dict):
+    """Authorize function call result"""
+
+    enterprise_id: Optional[str]
+    team_id: Optional[str]
+    team: Optional[str]  # since v1.18
+    url: Optional[str]  # since v1.18
+
+    bot_id: Optional[str]
+    bot_user_id: Optional[str]
+    bot_token: Optional[str]
+    bot_scopes: Optional[Sequence[str]]  # since v1.17
+
+    user_id: Optional[str]
+    user: Optional[str]  # since v1.18
+    user_token: Optional[str]
+    user_scopes: Optional[Sequence[str]]  # since v1.17
+
+    def __init__(
+        self,
+        *,
+        enterprise_id: Optional[str],
+        team_id: Optional[str],
+        team: Optional[str] = None,
+        url: Optional[str] = None,
+        # bot
+        bot_user_id: Optional[str] = None,
+        bot_id: Optional[str] = None,
+        bot_token: Optional[str] = None,
+        bot_scopes: Optional[Union[Sequence[str], str]] = None,
+        # user
+        user_id: Optional[str] = None,
+        user: Optional[str] = None,
+        user_token: Optional[str] = None,
+        user_scopes: Optional[Union[Sequence[str], str]] = None,
+    ):
+        """
+        Args:
+            enterprise_id: Organization ID (Enterprise Grid) starting with `E`
+            team_id: Workspace ID starting with `T`
+            team: Workspace name
+            url: Workspace slack.com URL
+            bot_user_id: Bot user's User ID starting with either `U` or `W`
+            bot_id: Bot ID starting with `B`
+            bot_token: Bot user access token starting with `xoxb-`
+            bot_scopes: The scopes associated with the bot token
+            user_id: The request user ID
+            user: The request user's name
+            user_token: User access token starting with `xoxp-`
+            user_scopes: The scopes associated wth the user token
+        """
+        self["enterprise_id"] = self.enterprise_id = enterprise_id
+        self["team_id"] = self.team_id = team_id
+        self["team"] = self.team = team
+        self["url"] = self.url = url
+        # bot
+        self["bot_user_id"] = self.bot_user_id = bot_user_id
+        self["bot_id"] = self.bot_id = bot_id
+        self["bot_token"] = self.bot_token = bot_token
+        if bot_scopes is not None and isinstance(bot_scopes, str):
+            bot_scopes = [scope.strip() for scope in bot_scopes.split(",")]
+        self["bot_scopes"] = self.bot_scopes = bot_scopes
+        # user
+        self["user_id"] = self.user_id = user_id
+        self["user"] = self.user = user
+        self["user_token"] = self.user_token = user_token
+        if user_scopes is not None and isinstance(user_scopes, str):
+            user_scopes = [scope.strip() for scope in user_scopes.split(",")]
+        self["user_scopes"] = self.user_scopes = user_scopes
+
+    @classmethod
+    def from_auth_test_response(
+        cls,
+        *,
+        bot_token: Optional[str] = None,
+        user_token: Optional[str] = None,
+        bot_scopes: Optional[Union[Sequence[str], str]] = None,
+        user_scopes: Optional[Union[Sequence[str], str]] = None,
+        auth_test_response: Union[SlackResponse, "AsyncSlackResponse"],  # type: ignore[name-defined]
+        user_auth_test_response: Optional[Union[SlackResponse, "AsyncSlackResponse"]] = None,  # type: ignore[name-defined]
+    ) -> "AuthorizeResult":
+        bot_user_id: Optional[str] = (
+            auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None
+        )
+        user_id: Optional[str] = auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None
+        user_name: Optional[str] = auth_test_response.get("user")
+        if user_id is None and user_auth_test_response is not None:
+            user_id = user_auth_test_response.get("user_id")
+            user_name = user_auth_test_response.get("user")
+
+        return AuthorizeResult(
+            enterprise_id=auth_test_response.get("enterprise_id"),
+            team_id=auth_test_response.get("team_id"),
+            team=auth_test_response.get("team"),
+            url=auth_test_response.get("url"),
+            bot_id=auth_test_response.get("bot_id"),
+            bot_user_id=bot_user_id,
+            bot_scopes=bot_scopes,
+            user_id=user_id,
+            user=user_name,
+            bot_token=bot_token,
+            user_token=user_token,
+            user_scopes=user_scopes,
+        )
+
+

Authorize function call result

+

Args

+
+
enterprise_id
+
Organization ID (Enterprise Grid) starting with E
+
team_id
+
Workspace ID starting with T
+
team
+
Workspace name
+
url
+
Workspace slack.com URL
+
bot_user_id
+
Bot user's User ID starting with either U or W
+
bot_id
+
Bot ID starting with B
+
bot_token
+
Bot user access token starting with xoxb-
+
bot_scopes
+
The scopes associated with the bot token
+
user_id
+
The request user ID
+
user
+
The request user's name
+
user_token
+
User access token starting with xoxp-
+
user_scopes
+
The scopes associated wth the user token
+
+

Ancestors

+
    +
  • builtins.dict
  • +
+

Class variables

+
+
var bot_id : str | None
+
+

The type of the None singleton.

+
+
var bot_scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+
var bot_token : str | None
+
+

The type of the None singleton.

+
+
var bot_user_id : str | None
+
+

The type of the None singleton.

+
+
var enterprise_id : str | None
+
+

The type of the None singleton.

+
+
var team : str | None
+
+

The type of the None singleton.

+
+
var team_id : str | None
+
+

The type of the None singleton.

+
+
var url : str | None
+
+

The type of the None singleton.

+
+
var user : str | None
+
+

The type of the None singleton.

+
+
var user_id : str | None
+
+

The type of the None singleton.

+
+
var user_scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+
var user_token : str | None
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def from_auth_test_response(*,
bot_token: str | None = None,
user_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_scopes: Sequence[str] | str | None = None,
auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/ack/ack.html b/docs/reference/context/ack/ack.html new file mode 100644 index 000000000..a8b808d86 --- /dev/null +++ b/docs/reference/context/ack/ack.html @@ -0,0 +1,133 @@ + + + + + + +slack_bolt.context.ack.ack API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.ack.ack

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Ack +
+
+
+ +Expand source code + +
class Ack:
+    response: Optional[BoltResponse]
+
+    def __init__(self):
+        self.response: Optional[BoltResponse] = None
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",  # text: str or whole_response: dict
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        response_type: Optional[str] = None,  # in_channel / ephemeral
+        # block_suggestion / dialog_suggestion
+        options: Optional[Sequence[Union[dict, Option]]] = None,
+        option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None,
+        # view_submission
+        response_action: Optional[str] = None,  # errors / update / push / clear
+        errors: Optional[Dict[str, str]] = None,
+        view: Optional[Union[dict, View]] = None,
+    ) -> BoltResponse:
+        return _set_response(
+            self,
+            text_or_whole_response=text,
+            blocks=blocks,
+            attachments=attachments,
+            unfurl_links=unfurl_links,
+            unfurl_media=unfurl_media,
+            response_type=response_type,
+            options=options,
+            option_groups=option_groups,
+            response_action=response_action,
+            errors=errors,
+            view=view,
+        )
+
+
+

Class variables

+
+
var responseBoltResponse | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/ack/async_ack.html b/docs/reference/context/ack/async_ack.html new file mode 100644 index 000000000..f744d5693 --- /dev/null +++ b/docs/reference/context/ack/async_ack.html @@ -0,0 +1,133 @@ + + + + + + +slack_bolt.context.ack.async_ack API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.ack.async_ack

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAck +
+
+
+ +Expand source code + +
class AsyncAck:
+    response: Optional[BoltResponse]
+
+    def __init__(self):
+        self.response: Optional[BoltResponse] = None
+
+    async def __call__(
+        self,
+        text: Union[str, dict] = "",  # text: str or whole_response: dict
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        response_type: Optional[str] = None,  # in_channel / ephemeral
+        # block_suggestion / dialog_suggestion
+        options: Optional[Sequence[Union[dict, Option]]] = None,
+        option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None,
+        # view_submission
+        response_action: Optional[str] = None,  # errors / update / push / clear
+        errors: Optional[Dict[str, str]] = None,
+        view: Optional[Union[dict, View]] = None,
+    ) -> BoltResponse:
+        return _set_response(
+            self,
+            text_or_whole_response=text,
+            blocks=blocks,
+            attachments=attachments,
+            unfurl_links=unfurl_links,
+            unfurl_media=unfurl_media,
+            response_type=response_type,
+            options=options,
+            option_groups=option_groups,
+            response_action=response_action,
+            errors=errors,
+            view=view,
+        )
+
+
+

Class variables

+
+
var responseBoltResponse | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/ack/index.html b/docs/reference/context/ack/index.html new file mode 100644 index 000000000..89f0600e8 --- /dev/null +++ b/docs/reference/context/ack/index.html @@ -0,0 +1,155 @@ + + + + + + +slack_bolt.context.ack API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.ack

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.ack.ack
+
+
+
+
slack_bolt.context.ack.async_ack
+
+
+
+
slack_bolt.context.ack.internals
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Ack +
+
+
+ +Expand source code + +
class Ack:
+    response: Optional[BoltResponse]
+
+    def __init__(self):
+        self.response: Optional[BoltResponse] = None
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",  # text: str or whole_response: dict
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        response_type: Optional[str] = None,  # in_channel / ephemeral
+        # block_suggestion / dialog_suggestion
+        options: Optional[Sequence[Union[dict, Option]]] = None,
+        option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None,
+        # view_submission
+        response_action: Optional[str] = None,  # errors / update / push / clear
+        errors: Optional[Dict[str, str]] = None,
+        view: Optional[Union[dict, View]] = None,
+    ) -> BoltResponse:
+        return _set_response(
+            self,
+            text_or_whole_response=text,
+            blocks=blocks,
+            attachments=attachments,
+            unfurl_links=unfurl_links,
+            unfurl_media=unfurl_media,
+            response_type=response_type,
+            options=options,
+            option_groups=option_groups,
+            response_action=response_action,
+            errors=errors,
+            view=view,
+        )
+
+
+

Class variables

+
+
var responseBoltResponse | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/ack/internals.html b/docs/reference/context/ack/internals.html new file mode 100644 index 000000000..f7f776241 --- /dev/null +++ b/docs/reference/context/ack/internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.context.ack.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.ack.internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/assistant_utilities.html b/docs/reference/context/assistant/assistant_utilities.html new file mode 100644 index 000000000..2200c4f10 --- /dev/null +++ b/docs/reference/context/assistant/assistant_utilities.html @@ -0,0 +1,241 @@ + + + + + + +slack_bolt.context.assistant.assistant_utilities API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.assistant_utilities

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AssistantUtilities +(*,
payload: dict,
context: BoltContext,
thread_context_store: AssistantThreadContextStore | None = None)
+
+
+
+ +Expand source code + +
class AssistantUtilities:
+    payload: dict
+    client: WebClient
+    channel_id: str
+    thread_ts: str
+    thread_context_store: AssistantThreadContextStore
+
+    def __init__(
+        self,
+        *,
+        payload: dict,
+        context: BoltContext,
+        thread_context_store: Optional[AssistantThreadContextStore] = None,
+    ):
+        self.payload = payload
+        self.client = context.client
+        self.thread_context_store = thread_context_store or DefaultAssistantThreadContextStore(context)
+
+        if has_channel_id_and_thread_ts(self.payload):
+            # assistant_thread_started
+            thread = self.payload["assistant_thread"]
+            self.channel_id = thread["channel_id"]
+            self.thread_ts = thread["thread_ts"]
+        elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None:
+            # message event
+            self.channel_id = self.payload["channel"]
+            self.thread_ts = self.payload["thread_ts"]
+        else:
+            # 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})")
+
+    @property
+    def set_title(self) -> SetTitle:
+        return SetTitle(self.client, self.channel_id, self.thread_ts)
+
+    @property
+    def say(self) -> Say:
+        def build_metadata() -> Optional[dict]:
+            thread_context = self.get_thread_context()
+            if thread_context is not None:
+                return {"event_type": "assistant_thread_context", "event_payload": thread_context}
+            return None
+
+        return Say(
+            self.client,
+            channel=self.channel_id,
+            thread_ts=self.thread_ts,
+            build_metadata=build_metadata,
+        )
+
+    @property
+    def get_thread_context(self) -> GetThreadContext:
+        return GetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload)
+
+    @property
+    def save_thread_context(self) -> SaveThreadContext:
+        return SaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts)
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var payload : dict
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+

Instance variables

+
+
prop get_thread_contextGetThreadContext
+
+
+ +Expand source code + +
@property
+def get_thread_context(self) -> GetThreadContext:
+    return GetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload)
+
+
+
+
prop save_thread_contextSaveThreadContext
+
+
+ +Expand source code + +
@property
+def save_thread_context(self) -> SaveThreadContext:
+    return SaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts)
+
+
+
+
prop saySay
+
+
+ +Expand source code + +
@property
+def say(self) -> Say:
+    def build_metadata() -> Optional[dict]:
+        thread_context = self.get_thread_context()
+        if thread_context is not None:
+            return {"event_type": "assistant_thread_context", "event_payload": thread_context}
+        return None
+
+    return Say(
+        self.client,
+        channel=self.channel_id,
+        thread_ts=self.thread_ts,
+        build_metadata=build_metadata,
+    )
+
+
+
+
prop set_titleSetTitle
+
+
+ +Expand source code + +
@property
+def set_title(self) -> SetTitle:
+    return SetTitle(self.client, self.channel_id, self.thread_ts)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/async_assistant_utilities.html b/docs/reference/context/assistant/async_assistant_utilities.html new file mode 100644 index 000000000..70f4d0d23 --- /dev/null +++ b/docs/reference/context/assistant/async_assistant_utilities.html @@ -0,0 +1,235 @@ + + + + + + +slack_bolt.context.assistant.async_assistant_utilities API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.async_assistant_utilities

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAssistantUtilities +(*,
payload: dict,
context: AsyncBoltContext,
thread_context_store: AsyncAssistantThreadContextStore | None = None)
+
+
+
+ +Expand source code + +
class AsyncAssistantUtilities:
+    payload: dict
+    client: AsyncWebClient
+    channel_id: str
+    thread_ts: str
+    thread_context_store: AsyncAssistantThreadContextStore
+
+    def __init__(
+        self,
+        *,
+        payload: dict,
+        context: AsyncBoltContext,
+        thread_context_store: Optional[AsyncAssistantThreadContextStore] = None,
+    ):
+        self.payload = payload
+        self.client = context.client
+        self.thread_context_store = thread_context_store or DefaultAsyncAssistantThreadContextStore(context)
+
+        if has_channel_id_and_thread_ts(self.payload):
+            # assistant_thread_started
+            thread = self.payload["assistant_thread"]
+            self.channel_id = thread["channel_id"]
+            self.thread_ts = thread["thread_ts"]
+        elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None:
+            # message event
+            self.channel_id = self.payload["channel"]
+            self.thread_ts = self.payload["thread_ts"]
+        else:
+            # 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})")
+
+    @property
+    def set_title(self) -> AsyncSetTitle:
+        return AsyncSetTitle(self.client, self.channel_id, self.thread_ts)
+
+    @property
+    def say(self) -> AsyncSay:
+        return AsyncSay(
+            self.client,
+            channel=self.channel_id,
+            thread_ts=self.thread_ts,
+            build_metadata=self._build_message_metadata,
+        )
+
+    async def _build_message_metadata(self) -> dict:
+        return {
+            "event_type": "assistant_thread_context",
+            "event_payload": await self.get_thread_context(),
+        }
+
+    @property
+    def get_thread_context(self) -> AsyncGetThreadContext:
+        return AsyncGetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload)
+
+    @property
+    def save_thread_context(self) -> AsyncSaveThreadContext:
+        return AsyncSaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts)
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var payload : dict
+
+

The type of the None singleton.

+
+
var thread_context_storeAsyncAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+

Instance variables

+
+
prop get_thread_contextAsyncGetThreadContext
+
+
+ +Expand source code + +
@property
+def get_thread_context(self) -> AsyncGetThreadContext:
+    return AsyncGetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload)
+
+
+
+
prop save_thread_contextAsyncSaveThreadContext
+
+
+ +Expand source code + +
@property
+def save_thread_context(self) -> AsyncSaveThreadContext:
+    return AsyncSaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts)
+
+
+
+
prop sayAsyncSay
+
+
+ +Expand source code + +
@property
+def say(self) -> AsyncSay:
+    return AsyncSay(
+        self.client,
+        channel=self.channel_id,
+        thread_ts=self.thread_ts,
+        build_metadata=self._build_message_metadata,
+    )
+
+
+
+
prop set_titleAsyncSetTitle
+
+
+ +Expand source code + +
@property
+def set_title(self) -> AsyncSetTitle:
+    return AsyncSetTitle(self.client, self.channel_id, self.thread_ts)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/index.html b/docs/reference/context/assistant/index.html new file mode 100644 index 000000000..d442e26cf --- /dev/null +++ b/docs/reference/context/assistant/index.html @@ -0,0 +1,98 @@ + + + + + + +slack_bolt.context.assistant API documentation + + + + + + + + + + + +
+ + +
+ + + diff --git a/docs/reference/context/assistant/internals.html b/docs/reference/context/assistant/internals.html new file mode 100644 index 000000000..242bd6f19 --- /dev/null +++ b/docs/reference/context/assistant/internals.html @@ -0,0 +1,95 @@ + + + + + + +slack_bolt.context.assistant.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.internals

+
+
+
+
+
+
+
+
+

Functions

+
+
+def has_channel_id_and_thread_ts(payload: dict) ‑> bool +
+
+
+ +Expand source code + +
def has_channel_id_and_thread_ts(payload: dict) -> bool:
+    """Verifies if the given payload has both channel_id and thread_ts under assistant_thread property.
+    This data pattern is available for assistant_* events.
+    """
+    return (
+        payload.get("assistant_thread") is not None
+        and payload["assistant_thread"].get("channel_id") is not None
+        and payload["assistant_thread"].get("thread_ts") is not None
+    )
+
+

Verifies if the given payload has both channel_id and thread_ts under assistant_thread property. +This data pattern is available for assistant_* events.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/thread_context/index.html b/docs/reference/context/assistant/thread_context/index.html new file mode 100644 index 000000000..f3767a1cf --- /dev/null +++ b/docs/reference/context/assistant/thread_context/index.html @@ -0,0 +1,132 @@ + + + + + + +slack_bolt.context.assistant.thread_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.thread_context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AssistantThreadContext +(payload: dict) +
+
+
+ +Expand source code + +
class AssistantThreadContext(dict):
+    enterprise_id: Optional[str]
+    team_id: Optional[str]
+    channel_id: str
+
+    def __init__(self, payload: dict):
+        dict.__init__(self, **payload)
+        self.enterprise_id = payload.get("enterprise_id")
+        self.team_id = payload.get("team_id")
+        self.channel_id = payload["channel_id"]
+
+

dict() -> new empty dictionary +dict(mapping) -> new dictionary initialized from a mapping object's +(key, value) pairs +dict(iterable) -> new dictionary initialized as if via: +d = {} +for k, v in iterable: +d[k] = v +dict(**kwargs) -> new dictionary initialized with the name=value pairs +in the keyword argument list. +For example: +dict(one=1, two=2)

+

Ancestors

+
    +
  • builtins.dict
  • +
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var enterprise_id : str | None
+
+

The type of the None singleton.

+
+
var team_id : str | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/thread_context_store/async_store.html b/docs/reference/context/assistant/thread_context_store/async_store.html new file mode 100644 index 000000000..64f4e53ed --- /dev/null +++ b/docs/reference/context/assistant/thread_context_store/async_store.html @@ -0,0 +1,130 @@ + + + + + + +slack_bolt.context.assistant.thread_context_store.async_store API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.thread_context_store.async_store

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAssistantThreadContextStore +
+
+
+ +Expand source code + +
class AsyncAssistantThreadContextStore:
+    async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+        raise NotImplementedError()
+
+    async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+async def find(self, *, channel_id: str, thread_ts: str) ‑> AssistantThreadContext | None +
+
+
+ +Expand source code + +
async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+    raise NotImplementedError()
+
+
+
+
+async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) ‑> None +
+
+
+ +Expand source code + +
async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+    raise NotImplementedError()
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/thread_context_store/default_async_store.html b/docs/reference/context/assistant/thread_context_store/default_async_store.html new file mode 100644 index 000000000..f6cd66060 --- /dev/null +++ b/docs/reference/context/assistant/thread_context_store/default_async_store.html @@ -0,0 +1,196 @@ + + + + + + +slack_bolt.context.assistant.thread_context_store.default_async_store API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.thread_context_store.default_async_store

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class DefaultAsyncAssistantThreadContextStore +(context: AsyncBoltContext) +
+
+
+ +Expand source code + +
class DefaultAsyncAssistantThreadContextStore(AsyncAssistantThreadContextStore):
+    client: AsyncWebClient
+    context: AsyncBoltContext
+
+    def __init__(self, context: AsyncBoltContext):
+        self.client = context.client
+        self.context = context
+
+    async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+        parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts)
+        if parent_message is not None:
+            await self.client.chat_update(
+                channel=channel_id,
+                ts=parent_message["ts"],
+                text=parent_message["text"],
+                blocks=parent_message["blocks"],
+                metadata={
+                    "event_type": "assistant_thread_context",
+                    "event_payload": context,
+                },
+            )
+
+    async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+        parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts)
+        if parent_message is not None and parent_message.get("metadata"):
+            if bool(parent_message["metadata"]["event_payload"]):
+                return AssistantThreadContext(parent_message["metadata"]["event_payload"])
+        return None
+
+    async def _retrieve_first_bot_reply(self, channel_id: str, thread_ts: str) -> Optional[dict]:
+        messages: List[dict] = (
+            await self.client.conversations_replies(
+                channel=channel_id,
+                ts=thread_ts,
+                oldest=thread_ts,
+                include_all_metadata=True,
+                limit=4,  # 2 should be usually enough but buffer for more robustness
+            )
+        ).get("messages", [])
+        for message in messages:
+            if message.get("subtype") is None and message.get("user") == self.context.bot_user_id:
+                return message
+        return None
+
+
+

Ancestors

+ +

Class variables

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var contextAsyncBoltContext
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def find(self, *, channel_id: str, thread_ts: str) ‑> AssistantThreadContext | None +
+
+
+ +Expand source code + +
async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+    parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts)
+    if parent_message is not None and parent_message.get("metadata"):
+        if bool(parent_message["metadata"]["event_payload"]):
+            return AssistantThreadContext(parent_message["metadata"]["event_payload"])
+    return None
+
+
+
+
+async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) ‑> None +
+
+
+ +Expand source code + +
async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+    parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts)
+    if parent_message is not None:
+        await self.client.chat_update(
+            channel=channel_id,
+            ts=parent_message["ts"],
+            text=parent_message["text"],
+            blocks=parent_message["blocks"],
+            metadata={
+                "event_type": "assistant_thread_context",
+                "event_payload": context,
+            },
+        )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/thread_context_store/default_store.html b/docs/reference/context/assistant/thread_context_store/default_store.html new file mode 100644 index 000000000..1594c5d38 --- /dev/null +++ b/docs/reference/context/assistant/thread_context_store/default_store.html @@ -0,0 +1,194 @@ + + + + + + +slack_bolt.context.assistant.thread_context_store.default_store API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.thread_context_store.default_store

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class DefaultAssistantThreadContextStore +(context: BoltContext) +
+
+
+ +Expand source code + +
class DefaultAssistantThreadContextStore(AssistantThreadContextStore):
+    client: WebClient
+    context: "BoltContext"
+
+    def __init__(self, context: BoltContext):
+        self.client = context.client
+        self.context = context
+
+    def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+        parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts)
+        if parent_message is not None:
+            self.client.chat_update(
+                channel=channel_id,
+                ts=parent_message["ts"],
+                text=parent_message["text"],
+                blocks=parent_message["blocks"],
+                metadata={
+                    "event_type": "assistant_thread_context",
+                    "event_payload": context,
+                },
+            )
+
+    def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+        parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts)
+        if parent_message is not None and parent_message.get("metadata"):
+            if bool(parent_message["metadata"]["event_payload"]):
+                return AssistantThreadContext(parent_message["metadata"]["event_payload"])
+        return None
+
+    def _retrieve_first_bot_reply(self, channel_id: str, thread_ts: str) -> Optional[dict]:
+        messages: List[dict] = self.client.conversations_replies(
+            channel=channel_id,
+            ts=thread_ts,
+            oldest=thread_ts,
+            include_all_metadata=True,
+            limit=4,  # 2 should be usually enough but buffer for more robustness
+        ).get("messages", [])
+        for message in messages:
+            if message.get("subtype") is None and message.get("user") == self.context.bot_user_id:
+                return message
+        return None
+
+
+

Ancestors

+ +

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var contextBoltContext
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def find(self, *, channel_id: str, thread_ts: str) ‑> AssistantThreadContext | None +
+
+
+ +Expand source code + +
def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+    parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts)
+    if parent_message is not None and parent_message.get("metadata"):
+        if bool(parent_message["metadata"]["event_payload"]):
+            return AssistantThreadContext(parent_message["metadata"]["event_payload"])
+    return None
+
+
+
+
+def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) ‑> None +
+
+
+ +Expand source code + +
def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+    parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts)
+    if parent_message is not None:
+        self.client.chat_update(
+            channel=channel_id,
+            ts=parent_message["ts"],
+            text=parent_message["text"],
+            blocks=parent_message["blocks"],
+            metadata={
+                "event_type": "assistant_thread_context",
+                "event_payload": context,
+            },
+        )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/thread_context_store/file/index.html b/docs/reference/context/assistant/thread_context_store/file/index.html new file mode 100644 index 000000000..4a5d944e1 --- /dev/null +++ b/docs/reference/context/assistant/thread_context_store/file/index.html @@ -0,0 +1,165 @@ + + + + + + +slack_bolt.context.assistant.thread_context_store.file API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.thread_context_store.file

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class FileAssistantThreadContextStore +(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts') +
+
+
+ +Expand source code + +
class FileAssistantThreadContextStore(AssistantThreadContextStore):
+
+    def __init__(
+        self,
+        base_dir: str = str(Path.home()) + "/.bolt-app-assistant-thread-contexts",
+    ):
+        self.base_dir = base_dir
+        self._mkdir(self.base_dir)
+
+    def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+        path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+        with open(path, "w") as f:
+            f.write(json.dumps(context))
+
+    def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+        path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+        try:
+            with open(path) as f:
+                data = json.loads(f.read())
+                if data.get("channel_id") is not None:
+                    return AssistantThreadContext(data)
+        except FileNotFoundError:
+            pass
+        return None
+
+    @staticmethod
+    def _mkdir(path: Union[str, Path]):
+        if isinstance(path, str):
+            path = Path(path)
+        path.mkdir(parents=True, exist_ok=True)
+
+
+

Ancestors

+ +

Methods

+
+
+def find(self, *, channel_id: str, thread_ts: str) ‑> AssistantThreadContext | None +
+
+
+ +Expand source code + +
def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+    path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+    try:
+        with open(path) as f:
+            data = json.loads(f.read())
+            if data.get("channel_id") is not None:
+                return AssistantThreadContext(data)
+    except FileNotFoundError:
+        pass
+    return None
+
+
+
+
+def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) ‑> None +
+
+
+ +Expand source code + +
def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+    path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+    with open(path, "w") as f:
+        f.write(json.dumps(context))
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/assistant/thread_context_store/index.html b/docs/reference/context/assistant/thread_context_store/index.html new file mode 100644 index 000000000..3083275d9 --- /dev/null +++ b/docs/reference/context/assistant/thread_context_store/index.html @@ -0,0 +1,98 @@ + + + + + + +slack_bolt.context.assistant.thread_context_store API documentation + + + + + + + + + + + +
+ + +
+ + + diff --git a/docs/reference/context/assistant/thread_context_store/store.html b/docs/reference/context/assistant/thread_context_store/store.html new file mode 100644 index 000000000..a0a177b09 --- /dev/null +++ b/docs/reference/context/assistant/thread_context_store/store.html @@ -0,0 +1,131 @@ + + + + + + +slack_bolt.context.assistant.thread_context_store.store API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.assistant.thread_context_store.store

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AssistantThreadContextStore +
+
+
+ +Expand source code + +
class AssistantThreadContextStore:
+    def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+        raise NotImplementedError()
+
+    def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+def find(self, *, channel_id: str, thread_ts: str) ‑> AssistantThreadContext | None +
+
+
+ +Expand source code + +
def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+    raise NotImplementedError()
+
+
+
+
+def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) ‑> None +
+
+
+ +Expand source code + +
def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+    raise NotImplementedError()
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/async_context.html b/docs/reference/context/async_context.html new file mode 100644 index 000000000..8fc6d36bf --- /dev/null +++ b/docs/reference/context/async_context.html @@ -0,0 +1,729 @@ + + + + + + +slack_bolt.context.async_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.async_context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncBoltContext +(*args, **kwargs) +
+
+
+ +Expand source code + +
class AsyncBoltContext(BaseContext):
+    """Context object associated with a request from Slack."""
+
+    def to_copyable(self) -> "AsyncBoltContext":
+        new_dict = {}
+        for prop_name, prop_value in self.items():
+            if prop_name in self.copyable_standard_property_names:
+                # all the standard properties are copiable
+                new_dict[prop_name] = prop_value
+            elif prop_name in self.non_copyable_standard_property_names:
+                # Do nothing with this property (e.g., listener_runner)
+                continue
+            else:
+                try:
+                    copied_value = create_copy(prop_value)
+                    new_dict[prop_name] = copied_value
+                except TypeError as te:
+                    self.logger.debug(
+                        f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                        f"as it's not possible to make a deep copy (error: {te})"
+                    )
+        return AsyncBoltContext(new_dict)
+
+    # The return type is intentionally string to avoid circular imports
+    @property
+    def listener_runner(self) -> "AsyncioListenerRunner":
+        """The properly configured listener_runner that is available for middleware/listeners."""
+        return self["listener_runner"]
+
+    @property
+    def client(self) -> AsyncWebClient:
+        """The `AsyncWebClient` instance available for this request.
+
+            @app.event("app_mention")
+            async def handle_events(context):
+                await context.client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+            # You can access "client" this way too.
+            @app.event("app_mention")
+            async def handle_events(client, context):
+                await client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+        Returns:
+            `AsyncWebClient` instance
+        """
+        if "client" not in self:
+            self["client"] = AsyncWebClient(token=None)
+        return self["client"]
+
+    @property
+    def ack(self) -> AsyncAck:
+        """`ack()` function for this request.
+
+            @app.action("button")
+            async def handle_button_clicks(context):
+                await context.ack()
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            async def handle_button_clicks(ack):
+                await ack()
+
+        Returns:
+            Callable `ack()` function
+        """
+        if "ack" not in self:
+            self["ack"] = AsyncAck()
+        return self["ack"]
+
+    @property
+    def say(self) -> AsyncSay:
+        """`say()` function for this request.
+
+            @app.action("button")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.say("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            async def handle_button_clicks(ack, say):
+                await ack()
+                await say("Hi!")
+
+        Returns:
+            Callable `say()` function
+        """
+        if "say" not in self:
+            self["say"] = AsyncSay(client=self.client, channel=self.channel_id)
+        return self["say"]
+
+    @property
+    def respond(self) -> Optional[AsyncRespond]:
+        """`respond()` function for this request.
+
+            @app.action("button")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.respond("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            async def handle_button_clicks(ack, respond):
+                await ack()
+                await respond("Hi!")
+
+        Returns:
+            Callable `respond()` function
+        """
+        if "respond" not in self:
+            self["respond"] = AsyncRespond(
+                response_url=self.response_url,
+                proxy=self.client.proxy,
+                ssl=self.client.ssl,
+            )
+        return self["respond"]
+
+    @property
+    def complete(self) -> AsyncComplete:
+        """`complete()` function for this request. Once a custom function's state is set to complete,
+        any outputs the function returns will be passed along to the next step of its housing workflow,
+        or complete the workflow if the function is the last step in a workflow. Additionally,
+        any interactivity handlers associated to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            async def handle_button_clicks(ack, complete):
+                await ack()
+                await complete(outputs={"stringReverse":"olleh"})
+
+            @app.function("reverse")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.complete(outputs={"stringReverse":"olleh"})
+
+        Returns:
+            Callable `complete()` function
+        """
+        if "complete" not in self:
+            self["complete"] = AsyncComplete(client=self.client, function_execution_id=self.function_execution_id)
+        return self["complete"]
+
+    @property
+    def fail(self) -> AsyncFail:
+        """`fail()` function for this request. Once a custom function's state is set to error,
+        its housing workflow will be interrupted and any provided error message will be passed
+        on to the end user through SlackBot. Additionally, any interactivity handlers associated
+        to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            async def handle_button_clicks(ack, fail):
+                await ack()
+                await fail(error="something went wrong")
+
+            @app.function("reverse")
+            async def handle_button_clicks(context):
+                await context.ack()
+                await context.fail(error="something went wrong")
+
+        Returns:
+            Callable `fail()` function
+        """
+        if "fail" not in self:
+            self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id)
+        return self["fail"]
+
+    @property
+    def set_title(self) -> Optional[AsyncSetTitle]:
+        return self.get("set_title")
+
+    @property
+    def set_status(self) -> Optional[AsyncSetStatus]:
+        return self.get("set_status")
+
+    @property
+    def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]:
+        return self.get("set_suggested_prompts")
+
+    @property
+    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")
+
+

Context object associated with a request from Slack.

+

Ancestors

+ +

Instance variables

+
+
prop ackAsyncAck
+
+
+ +Expand source code + +
@property
+def ack(self) -> AsyncAck:
+    """`ack()` function for this request.
+
+        @app.action("button")
+        async def handle_button_clicks(context):
+            await context.ack()
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        async def handle_button_clicks(ack):
+            await ack()
+
+    Returns:
+        Callable `ack()` function
+    """
+    if "ack" not in self:
+        self["ack"] = AsyncAck()
+    return self["ack"]
+
+

ack() function for this request.

+
@app.action("button")
+async def handle_button_clicks(context):
+    await context.ack()
+
+# You can access "ack" this way too.
+@app.action("button")
+async def handle_button_clicks(ack):
+    await ack()
+
+

Returns

+

Callable ack() function

+
+
prop client : slack_sdk.web.async_client.AsyncWebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> AsyncWebClient:
+    """The `AsyncWebClient` instance available for this request.
+
+        @app.event("app_mention")
+        async def handle_events(context):
+            await context.client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+        # You can access "client" this way too.
+        @app.event("app_mention")
+        async def handle_events(client, context):
+            await client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+    Returns:
+        `AsyncWebClient` instance
+    """
+    if "client" not in self:
+        self["client"] = AsyncWebClient(token=None)
+    return self["client"]
+
+

The AsyncWebClient instance available for this request.

+
@app.event("app_mention")
+async def handle_events(context):
+    await context.client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+# You can access "client" this way too.
+@app.event("app_mention")
+async def handle_events(client, context):
+    await client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+

Returns

+

AsyncWebClient instance

+
+
prop completeAsyncComplete
+
+
+ +Expand source code + +
@property
+def complete(self) -> AsyncComplete:
+    """`complete()` function for this request. Once a custom function's state is set to complete,
+    any outputs the function returns will be passed along to the next step of its housing workflow,
+    or complete the workflow if the function is the last step in a workflow. Additionally,
+    any interactivity handlers associated to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        async def handle_button_clicks(ack, complete):
+            await ack()
+            await complete(outputs={"stringReverse":"olleh"})
+
+        @app.function("reverse")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.complete(outputs={"stringReverse":"olleh"})
+
+    Returns:
+        Callable `complete()` function
+    """
+    if "complete" not in self:
+        self["complete"] = AsyncComplete(client=self.client, function_execution_id=self.function_execution_id)
+    return self["complete"]
+
+

complete() function for this request. Once a custom function's state is set to complete, +any outputs the function returns will be passed along to the next step of its housing workflow, +or complete the workflow if the function is the last step in a workflow. Additionally, +any interactivity handlers associated to a function invocation will no longer be invocable.

+
@app.function("reverse")
+async def handle_button_clicks(ack, complete):
+    await ack()
+    await complete(outputs={"stringReverse":"olleh"})
+
+@app.function("reverse")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.complete(outputs={"stringReverse":"olleh"})
+
+

Returns

+

Callable complete() function

+
+
prop failAsyncFail
+
+
+ +Expand source code + +
@property
+def fail(self) -> AsyncFail:
+    """`fail()` function for this request. Once a custom function's state is set to error,
+    its housing workflow will be interrupted and any provided error message will be passed
+    on to the end user through SlackBot. Additionally, any interactivity handlers associated
+    to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        async def handle_button_clicks(ack, fail):
+            await ack()
+            await fail(error="something went wrong")
+
+        @app.function("reverse")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.fail(error="something went wrong")
+
+    Returns:
+        Callable `fail()` function
+    """
+    if "fail" not in self:
+        self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id)
+    return self["fail"]
+
+

fail() function for this request. Once a custom function's state is set to error, +its housing workflow will be interrupted and any provided error message will be passed +on to the end user through SlackBot. Additionally, any interactivity handlers associated +to a function invocation will no longer be invocable.

+
@app.function("reverse")
+async def handle_button_clicks(ack, fail):
+    await ack()
+    await fail(error="something went wrong")
+
+@app.function("reverse")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.fail(error="something went wrong")
+
+

Returns

+

Callable fail() function

+
+
prop get_thread_contextAsyncGetThreadContext | None
+
+
+ +Expand source code + +
@property
+def get_thread_context(self) -> Optional[AsyncGetThreadContext]:
+    return self.get("get_thread_context")
+
+
+
+
prop listener_runner : AsyncioListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> "AsyncioListenerRunner":
+    """The properly configured listener_runner that is available for middleware/listeners."""
+    return self["listener_runner"]
+
+

The properly configured listener_runner that is available for middleware/listeners.

+
+
prop respondAsyncRespond | None
+
+
+ +Expand source code + +
@property
+def respond(self) -> Optional[AsyncRespond]:
+    """`respond()` function for this request.
+
+        @app.action("button")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.respond("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        async def handle_button_clicks(ack, respond):
+            await ack()
+            await respond("Hi!")
+
+    Returns:
+        Callable `respond()` function
+    """
+    if "respond" not in self:
+        self["respond"] = AsyncRespond(
+            response_url=self.response_url,
+            proxy=self.client.proxy,
+            ssl=self.client.ssl,
+        )
+    return self["respond"]
+
+

respond() function for this request.

+
@app.action("button")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.respond("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+async def handle_button_clicks(ack, respond):
+    await ack()
+    await respond("Hi!")
+
+

Returns

+

Callable respond() function

+
+
prop save_thread_contextAsyncSaveThreadContext | None
+
+
+ +Expand source code + +
@property
+def save_thread_context(self) -> Optional[AsyncSaveThreadContext]:
+    return self.get("save_thread_context")
+
+
+
+
prop sayAsyncSay
+
+
+ +Expand source code + +
@property
+def say(self) -> AsyncSay:
+    """`say()` function for this request.
+
+        @app.action("button")
+        async def handle_button_clicks(context):
+            await context.ack()
+            await context.say("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        async def handle_button_clicks(ack, say):
+            await ack()
+            await say("Hi!")
+
+    Returns:
+        Callable `say()` function
+    """
+    if "say" not in self:
+        self["say"] = AsyncSay(client=self.client, channel=self.channel_id)
+    return self["say"]
+
+

say() function for this request.

+
@app.action("button")
+async def handle_button_clicks(context):
+    await context.ack()
+    await context.say("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+async def handle_button_clicks(ack, say):
+    await ack()
+    await say("Hi!")
+
+

Returns

+

Callable say() function

+
+
prop say_streamAsyncSayStream | None
+
+
+ +Expand source code + +
@property
+def say_stream(self) -> Optional[AsyncSayStream]:
+    return self.get("say_stream")
+
+
+
+
prop set_statusAsyncSetStatus | None
+
+
+ +Expand source code + +
@property
+def set_status(self) -> Optional[AsyncSetStatus]:
+    return self.get("set_status")
+
+
+
+
prop set_suggested_promptsAsyncSetSuggestedPrompts | None
+
+
+ +Expand source code + +
@property
+def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]:
+    return self.get("set_suggested_prompts")
+
+
+
+
prop set_titleAsyncSetTitle | None
+
+
+ +Expand source code + +
@property
+def set_title(self) -> Optional[AsyncSetTitle]:
+    return self.get("set_title")
+
+
+
+
+

Methods

+
+
+def to_copyable(self) ‑> AsyncBoltContext +
+
+
+ +Expand source code + +
def to_copyable(self) -> "AsyncBoltContext":
+    new_dict = {}
+    for prop_name, prop_value in self.items():
+        if prop_name in self.copyable_standard_property_names:
+            # all the standard properties are copiable
+            new_dict[prop_name] = prop_value
+        elif prop_name in self.non_copyable_standard_property_names:
+            # Do nothing with this property (e.g., listener_runner)
+            continue
+        else:
+            try:
+                copied_value = create_copy(prop_value)
+                new_dict[prop_name] = copied_value
+            except TypeError as te:
+                self.logger.debug(
+                    f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                    f"as it's not possible to make a deep copy (error: {te})"
+                )
+    return AsyncBoltContext(new_dict)
+
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/context/base_context.html b/docs/reference/context/base_context.html new file mode 100644 index 000000000..afe571163 --- /dev/null +++ b/docs/reference/context/base_context.html @@ -0,0 +1,647 @@ + + + + + + +slack_bolt.context.base_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.base_context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BaseContext +(*args, **kwargs) +
+
+
+ +Expand source code + +
class BaseContext(dict):
+    """Context object associated with a request from Slack."""
+
+    copyable_standard_property_names = [
+        "logger",
+        "token",
+        "enterprise_id",
+        "is_enterprise_install",
+        "team_id",
+        "user_id",
+        "actor_enterprise_id",
+        "actor_team_id",
+        "actor_user_id",
+        "channel_id",
+        "thread_ts",
+        "response_url",
+        "matches",
+        "authorize_result",
+        "function_bot_access_token",
+        "bot_token",
+        "bot_id",
+        "bot_user_id",
+        "user_token",
+        "function_execution_id",
+        "inputs",
+        "client",
+        "ack",
+        "say",
+        "respond",
+        "complete",
+        "fail",
+        "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.
+    # Other listener runners do not require the change because they invoke a lazy listener over the network,
+    # meaning that the context initialization would be done again.
+    non_copyable_standard_property_names = [
+        "listener_runner",
+        "get_thread_context",
+        "save_thread_context",
+    ]
+
+    standard_property_names = copyable_standard_property_names + non_copyable_standard_property_names
+
+    @property
+    def logger(self) -> Logger:
+        """The properly configured logger that is available for middleware/listeners."""
+        return self["logger"]
+
+    @property
+    def token(self) -> Optional[str]:
+        """The (bot/user) token resolved for this request."""
+        return self.get("token")
+
+    @property
+    def enterprise_id(self) -> Optional[str]:
+        """The Enterprise Grid Organization ID of this request."""
+        return self.get("enterprise_id")
+
+    @property
+    def is_enterprise_install(self) -> Optional[bool]:
+        """True if the request is associated with an Org-wide installation."""
+        return self.get("is_enterprise_install")
+
+    @property
+    def team_id(self) -> Optional[str]:
+        """The Workspace ID of this request."""
+        return self.get("team_id")
+
+    @property
+    def user_id(self) -> Optional[str]:
+        """The user ID associated ith this request."""
+        return self.get("user_id")
+
+    @property
+    def actor_enterprise_id(self) -> Optional[str]:
+        """The action's actor's Enterprise Grid organization ID.
+        Note that this property is especially useful for handling events in Slack Connect channels.
+        That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
+        """
+        return self.get("actor_enterprise_id")
+
+    @property
+    def actor_team_id(self) -> Optional[str]:
+        """The action's actor's workspace ID.
+        Note that this property is especially useful for handling events in Slack Connect channels.
+        That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
+        """
+        return self.get("actor_team_id")
+
+    @property
+    def actor_user_id(self) -> Optional[str]:
+        """The action's actor's user ID.
+        Note that this property is especially useful for handling events in Slack Connect channels.
+        That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
+        """
+        return self.get("actor_user_id")
+
+    @property
+    def channel_id(self) -> Optional[str]:
+        """The conversation ID associated with this request."""
+        return self.get("channel_id")
+
+    @property
+    def thread_ts(self) -> Optional[str]:
+        """The conversation thread's ID associated with this request."""
+        return self.get("thread_ts")
+
+    @property
+    def response_url(self) -> Optional[str]:
+        """The `response_url` associated with this request."""
+        return self.get("response_url")
+
+    @property
+    def matches(self) -> Optional[Tuple]:
+        """Returns all the matched parts in message listener's regexp"""
+        return self.get("matches")
+
+    @property
+    def function_execution_id(self) -> Optional[str]:
+        """The `function_execution_id` associated with this request.
+        Only available for `function_executed` and interactivity events scoped to a custom step.
+        """
+        return self.get("function_execution_id")
+
+    @property
+    def inputs(self) -> Optional[Dict[str, Any]]:
+        """The `inputs` associated with this request.
+        Only available for `function_executed` and interactivity events scoped to a custom step.
+        """
+        return self.get("inputs")
+
+    # --------------------------------
+
+    @property
+    def authorize_result(self) -> Optional[AuthorizeResult]:
+        """The authorize result resolved for this request."""
+        return self.get("authorize_result")
+
+    @property
+    def function_bot_access_token(self) -> Optional[str]:
+        """The bot token resolved for this function request.
+        Only available for `function_executed` and interactivity events scoped to a custom step.
+        """
+        return self.get("function_bot_access_token")
+
+    @property
+    def bot_token(self) -> Optional[str]:
+        """The bot token resolved for this request."""
+        return self.get("bot_token")
+
+    @property
+    def bot_id(self) -> Optional[str]:
+        """The bot ID resolved for this request."""
+        return self.get("bot_id")
+
+    @property
+    def bot_user_id(self) -> Optional[str]:
+        """The bot user ID resolved for this request."""
+        return self.get("bot_user_id")
+
+    @property
+    def user_token(self) -> Optional[str]:
+        """The user token resolved for this request."""
+        return self.get("user_token")
+
+    def set_authorize_result(self, authorize_result: AuthorizeResult):
+        self["authorize_result"] = authorize_result
+        if authorize_result.bot_id is not None:
+            self["bot_id"] = authorize_result.bot_id
+        if authorize_result.bot_user_id is not None:
+            self["bot_user_id"] = authorize_result.bot_user_id
+        if authorize_result.bot_token is not None:
+            self["bot_token"] = authorize_result.bot_token
+        if authorize_result.user_id is not None:
+            self["user_id"] = authorize_result.user_id
+        if authorize_result.user_token is not None:
+            self["user_token"] = authorize_result.user_token
+
+

Context object associated with a request from Slack.

+

Ancestors

+
    +
  • builtins.dict
  • +
+

Subclasses

+ +

Class variables

+
+
var copyable_standard_property_names
+
+

The type of the None singleton.

+
+
var non_copyable_standard_property_names
+
+

The type of the None singleton.

+
+
var standard_property_names
+
+

The type of the None singleton.

+
+
+

Instance variables

+
+
prop actor_enterprise_id : str | None
+
+
+ +Expand source code + +
@property
+def actor_enterprise_id(self) -> Optional[str]:
+    """The action's actor's Enterprise Grid organization ID.
+    Note that this property is especially useful for handling events in Slack Connect channels.
+    That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
+    """
+    return self.get("actor_enterprise_id")
+
+

The action's actor's Enterprise Grid organization ID. +Note that this property is especially useful for handling events in Slack Connect channels. +That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.

+
+
prop actor_team_id : str | None
+
+
+ +Expand source code + +
@property
+def actor_team_id(self) -> Optional[str]:
+    """The action's actor's workspace ID.
+    Note that this property is especially useful for handling events in Slack Connect channels.
+    That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
+    """
+    return self.get("actor_team_id")
+
+

The action's actor's workspace ID. +Note that this property is especially useful for handling events in Slack Connect channels. +That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.

+
+
prop actor_user_id : str | None
+
+
+ +Expand source code + +
@property
+def actor_user_id(self) -> Optional[str]:
+    """The action's actor's user ID.
+    Note that this property is especially useful for handling events in Slack Connect channels.
+    That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.
+    """
+    return self.get("actor_user_id")
+
+

The action's actor's user ID. +Note that this property is especially useful for handling events in Slack Connect channels. +That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency.

+
+
prop authorize_resultAuthorizeResult | None
+
+
+ +Expand source code + +
@property
+def authorize_result(self) -> Optional[AuthorizeResult]:
+    """The authorize result resolved for this request."""
+    return self.get("authorize_result")
+
+

The authorize result resolved for this request.

+
+
prop bot_id : str | None
+
+
+ +Expand source code + +
@property
+def bot_id(self) -> Optional[str]:
+    """The bot ID resolved for this request."""
+    return self.get("bot_id")
+
+

The bot ID resolved for this request.

+
+
prop bot_token : str | None
+
+
+ +Expand source code + +
@property
+def bot_token(self) -> Optional[str]:
+    """The bot token resolved for this request."""
+    return self.get("bot_token")
+
+

The bot token resolved for this request.

+
+
prop bot_user_id : str | None
+
+
+ +Expand source code + +
@property
+def bot_user_id(self) -> Optional[str]:
+    """The bot user ID resolved for this request."""
+    return self.get("bot_user_id")
+
+

The bot user ID resolved for this request.

+
+
prop channel_id : str | None
+
+
+ +Expand source code + +
@property
+def channel_id(self) -> Optional[str]:
+    """The conversation ID associated with this request."""
+    return self.get("channel_id")
+
+

The conversation ID associated with this request.

+
+
prop enterprise_id : str | None
+
+
+ +Expand source code + +
@property
+def enterprise_id(self) -> Optional[str]:
+    """The Enterprise Grid Organization ID of this request."""
+    return self.get("enterprise_id")
+
+

The Enterprise Grid Organization ID of this request.

+
+
prop function_bot_access_token : str | None
+
+
+ +Expand source code + +
@property
+def function_bot_access_token(self) -> Optional[str]:
+    """The bot token resolved for this function request.
+    Only available for `function_executed` and interactivity events scoped to a custom step.
+    """
+    return self.get("function_bot_access_token")
+
+

The bot token resolved for this function request. +Only available for function_executed and interactivity events scoped to a custom step.

+
+
prop function_execution_id : str | None
+
+
+ +Expand source code + +
@property
+def function_execution_id(self) -> Optional[str]:
+    """The `function_execution_id` associated with this request.
+    Only available for `function_executed` and interactivity events scoped to a custom step.
+    """
+    return self.get("function_execution_id")
+
+

The function_execution_id associated with this request. +Only available for function_executed and interactivity events scoped to a custom step.

+
+
prop inputs : Dict[str, Any] | None
+
+
+ +Expand source code + +
@property
+def inputs(self) -> Optional[Dict[str, Any]]:
+    """The `inputs` associated with this request.
+    Only available for `function_executed` and interactivity events scoped to a custom step.
+    """
+    return self.get("inputs")
+
+

The inputs associated with this request. +Only available for function_executed and interactivity events scoped to a custom step.

+
+
prop is_enterprise_install : bool | None
+
+
+ +Expand source code + +
@property
+def is_enterprise_install(self) -> Optional[bool]:
+    """True if the request is associated with an Org-wide installation."""
+    return self.get("is_enterprise_install")
+
+

True if the request is associated with an Org-wide installation.

+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> Logger:
+    """The properly configured logger that is available for middleware/listeners."""
+    return self["logger"]
+
+

The properly configured logger that is available for middleware/listeners.

+
+
prop matches : Tuple | None
+
+
+ +Expand source code + +
@property
+def matches(self) -> Optional[Tuple]:
+    """Returns all the matched parts in message listener's regexp"""
+    return self.get("matches")
+
+

Returns all the matched parts in message listener's regexp

+
+
prop response_url : str | None
+
+
+ +Expand source code + +
@property
+def response_url(self) -> Optional[str]:
+    """The `response_url` associated with this request."""
+    return self.get("response_url")
+
+

The response_url associated with this request.

+
+
prop team_id : str | None
+
+
+ +Expand source code + +
@property
+def team_id(self) -> Optional[str]:
+    """The Workspace ID of this request."""
+    return self.get("team_id")
+
+

The Workspace ID of this request.

+
+
prop thread_ts : str | None
+
+
+ +Expand source code + +
@property
+def thread_ts(self) -> Optional[str]:
+    """The conversation thread's ID associated with this request."""
+    return self.get("thread_ts")
+
+

The conversation thread's ID associated with this request.

+
+
prop token : str | None
+
+
+ +Expand source code + +
@property
+def token(self) -> Optional[str]:
+    """The (bot/user) token resolved for this request."""
+    return self.get("token")
+
+

The (bot/user) token resolved for this request.

+
+
prop user_id : str | None
+
+
+ +Expand source code + +
@property
+def user_id(self) -> Optional[str]:
+    """The user ID associated ith this request."""
+    return self.get("user_id")
+
+

The user ID associated ith this request.

+
+
prop user_token : str | None
+
+
+ +Expand source code + +
@property
+def user_token(self) -> Optional[str]:
+    """The user token resolved for this request."""
+    return self.get("user_token")
+
+

The user token resolved for this request.

+
+
+

Methods

+
+
+def set_authorize_result(self,
authorize_result: AuthorizeResult)
+
+
+
+ +Expand source code + +
def set_authorize_result(self, authorize_result: AuthorizeResult):
+    self["authorize_result"] = authorize_result
+    if authorize_result.bot_id is not None:
+        self["bot_id"] = authorize_result.bot_id
+    if authorize_result.bot_user_id is not None:
+        self["bot_user_id"] = authorize_result.bot_user_id
+    if authorize_result.bot_token is not None:
+        self["bot_token"] = authorize_result.bot_token
+    if authorize_result.user_id is not None:
+        self["user_id"] = authorize_result.user_id
+    if authorize_result.user_token is not None:
+        self["user_token"] = authorize_result.user_token
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/complete/async_complete.html b/docs/reference/context/complete/async_complete.html new file mode 100644 index 000000000..f0546a950 --- /dev/null +++ b/docs/reference/context/complete/async_complete.html @@ -0,0 +1,171 @@ + + + + + + +slack_bolt.context.complete.async_complete API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.complete.async_complete

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncComplete +(client: slack_sdk.web.async_client.AsyncWebClient,
function_execution_id: str | None)
+
+
+
+ +Expand source code + +
class AsyncComplete:
+    client: AsyncWebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    async def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> AsyncSlackResponse:
+        """Signal the successful completion of the custom function.
+
+        Kwargs:
+            outputs: Json serializable object containing the output values
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("complete is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return await self.client.functions_completeSuccess(
+            function_execution_id=self.function_execution_id, outputs=outputs or {}
+        )
+
+    def has_been_called(self) -> bool:
+        """Check if this complete function has been called.
+
+        Returns:
+            bool: True if the complete function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this complete function has been called.
+
+    Returns:
+        bool: True if the complete function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this complete function has been called.

+

Returns

+
+
bool
+
True if the complete function has been called, False otherwise.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/complete/complete.html b/docs/reference/context/complete/complete.html new file mode 100644 index 000000000..b8c1b083b --- /dev/null +++ b/docs/reference/context/complete/complete.html @@ -0,0 +1,169 @@ + + + + + + +slack_bolt.context.complete.complete API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.complete.complete

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Complete +(client: slack_sdk.web.client.WebClient, function_execution_id: str | None) +
+
+
+ +Expand source code + +
class Complete:
+    client: WebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: WebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse:
+        """Signal the successful completion of the custom function.
+
+        Kwargs:
+            outputs: Json serializable object containing the output values
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("complete is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {})
+
+    def has_been_called(self) -> bool:
+        """Check if this complete function has been called.
+
+        Returns:
+            bool: True if the complete function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this complete function has been called.
+
+    Returns:
+        bool: True if the complete function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this complete function has been called.

+

Returns

+
+
bool
+
True if the complete function has been called, False otherwise.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/complete/index.html b/docs/reference/context/complete/index.html new file mode 100644 index 000000000..dddd26a84 --- /dev/null +++ b/docs/reference/context/complete/index.html @@ -0,0 +1,186 @@ + + + + + + +slack_bolt.context.complete API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.complete

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.complete.async_complete
+
+
+
+
slack_bolt.context.complete.complete
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Complete +(client: slack_sdk.web.client.WebClient, function_execution_id: str | None) +
+
+
+ +Expand source code + +
class Complete:
+    client: WebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: WebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse:
+        """Signal the successful completion of the custom function.
+
+        Kwargs:
+            outputs: Json serializable object containing the output values
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("complete is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {})
+
+    def has_been_called(self) -> bool:
+        """Check if this complete function has been called.
+
+        Returns:
+            bool: True if the complete function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this complete function has been called.
+
+    Returns:
+        bool: True if the complete function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this complete function has been called.

+

Returns

+
+
bool
+
True if the complete function has been called, False otherwise.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/context.html b/docs/reference/context/context.html new file mode 100644 index 000000000..a7b531c20 --- /dev/null +++ b/docs/reference/context/context.html @@ -0,0 +1,731 @@ + + + + + + +slack_bolt.context.context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BoltContext +(*args, **kwargs) +
+
+
+ +Expand source code + +
class BoltContext(BaseContext):
+    """Context object associated with a request from Slack."""
+
+    def to_copyable(self) -> "BoltContext":
+        new_dict = {}
+        for prop_name, prop_value in self.items():
+            if prop_name in self.copyable_standard_property_names:
+                # all the standard properties are copiable
+                new_dict[prop_name] = prop_value
+            elif prop_name in self.non_copyable_standard_property_names:
+                # Do nothing with this property (e.g., listener_runner)
+                continue
+            else:
+                try:
+                    copied_value = create_copy(prop_value)
+                    new_dict[prop_name] = copied_value
+                except TypeError as te:
+                    self.logger.warning(
+                        f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                        "due to a deep-copy creation error. Consider passing the value not as part of context object "
+                        f"(error: {te})"
+                    )
+        return BoltContext(new_dict)
+
+    # The return type is intentionally string to avoid circular imports
+    @property
+    def listener_runner(self) -> "ThreadListenerRunner":
+        """The properly configured listener_runner that is available for middleware/listeners."""
+        return self["listener_runner"]
+
+    @property
+    def client(self) -> WebClient:
+        """The `WebClient` instance available for this request.
+
+            @app.event("app_mention")
+            def handle_events(context):
+                context.client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+            # You can access "client" this way too.
+            @app.event("app_mention")
+            def handle_events(client, context):
+                client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+        Returns:
+            `WebClient` instance
+        """
+        if "client" not in self:
+            self["client"] = WebClient(token=None)
+        return self["client"]
+
+    @property
+    def ack(self) -> Ack:
+        """`ack()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack):
+                ack()
+
+        Returns:
+            Callable `ack()` function
+        """
+        if "ack" not in self:
+            self["ack"] = Ack()
+        return self["ack"]
+
+    @property
+    def say(self) -> Say:
+        """`say()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+                context.say("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack, say):
+                ack()
+                say("Hi!")
+
+        Returns:
+            Callable `say()` function
+        """
+        if "say" not in self:
+            self["say"] = Say(client=self.client, channel=self.channel_id)
+        return self["say"]
+
+    @property
+    def respond(self) -> Optional[Respond]:
+        """`respond()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+                context.respond("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack, respond):
+                ack()
+                respond("Hi!")
+
+        Returns:
+            Callable `respond()` function
+        """
+        if "respond" not in self:
+            self["respond"] = Respond(
+                response_url=self.response_url,
+                proxy=self.client.proxy,
+                ssl=self.client.ssl,
+            )
+        return self["respond"]
+
+    @property
+    def complete(self) -> Complete:
+        """`complete()` function for this request. Once a custom function's state is set to complete,
+        any outputs the function returns will be passed along to the next step of its housing workflow,
+        or complete the workflow if the function is the last step in a workflow. Additionally,
+        any interactivity handlers associated to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            def handle_button_clicks(ack, complete):
+                ack()
+                complete(outputs={"stringReverse":"olleh"})
+
+            @app.function("reverse")
+            def handle_button_clicks(context):
+                context.ack()
+                context.complete(outputs={"stringReverse":"olleh"})
+
+        Returns:
+            Callable `complete()` function
+        """
+        if "complete" not in self:
+            self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id)
+        return self["complete"]
+
+    @property
+    def fail(self) -> Fail:
+        """`fail()` function for this request. Once a custom function's state is set to error,
+        its housing workflow will be interrupted and any provided error message will be passed
+        on to the end user through SlackBot. Additionally, any interactivity handlers associated
+        to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            def handle_button_clicks(ack, fail):
+                ack()
+                fail(error="something went wrong")
+
+            @app.function("reverse")
+            def handle_button_clicks(context):
+                context.ack()
+                context.fail(error="something went wrong")
+
+        Returns:
+            Callable `fail()` function
+        """
+        if "fail" not in self:
+            self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id)
+        return self["fail"]
+
+    @property
+    def set_title(self) -> Optional[SetTitle]:
+        return self.get("set_title")
+
+    @property
+    def set_status(self) -> Optional[SetStatus]:
+        return self.get("set_status")
+
+    @property
+    def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
+        return self.get("set_suggested_prompts")
+
+    @property
+    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")
+
+

Context object associated with a request from Slack.

+

Ancestors

+ +

Instance variables

+
+
prop ackAck
+
+
+ +Expand source code + +
@property
+def ack(self) -> Ack:
+    """`ack()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack):
+            ack()
+
+    Returns:
+        Callable `ack()` function
+    """
+    if "ack" not in self:
+        self["ack"] = Ack()
+    return self["ack"]
+
+

ack() function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack):
+    ack()
+
+

Returns

+

Callable ack() function

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    """The `WebClient` instance available for this request.
+
+        @app.event("app_mention")
+        def handle_events(context):
+            context.client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+        # You can access "client" this way too.
+        @app.event("app_mention")
+        def handle_events(client, context):
+            client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+    Returns:
+        `WebClient` instance
+    """
+    if "client" not in self:
+        self["client"] = WebClient(token=None)
+    return self["client"]
+
+

The WebClient instance available for this request.

+
@app.event("app_mention")
+def handle_events(context):
+    context.client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+# You can access "client" this way too.
+@app.event("app_mention")
+def handle_events(client, context):
+    client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+

Returns

+

WebClient instance

+
+
prop completeComplete
+
+
+ +Expand source code + +
@property
+def complete(self) -> Complete:
+    """`complete()` function for this request. Once a custom function's state is set to complete,
+    any outputs the function returns will be passed along to the next step of its housing workflow,
+    or complete the workflow if the function is the last step in a workflow. Additionally,
+    any interactivity handlers associated to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        def handle_button_clicks(ack, complete):
+            ack()
+            complete(outputs={"stringReverse":"olleh"})
+
+        @app.function("reverse")
+        def handle_button_clicks(context):
+            context.ack()
+            context.complete(outputs={"stringReverse":"olleh"})
+
+    Returns:
+        Callable `complete()` function
+    """
+    if "complete" not in self:
+        self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id)
+    return self["complete"]
+
+

complete() function for this request. Once a custom function's state is set to complete, +any outputs the function returns will be passed along to the next step of its housing workflow, +or complete the workflow if the function is the last step in a workflow. Additionally, +any interactivity handlers associated to a function invocation will no longer be invocable.

+
@app.function("reverse")
+def handle_button_clicks(ack, complete):
+    ack()
+    complete(outputs={"stringReverse":"olleh"})
+
+@app.function("reverse")
+def handle_button_clicks(context):
+    context.ack()
+    context.complete(outputs={"stringReverse":"olleh"})
+
+

Returns

+

Callable complete() function

+
+
prop failFail
+
+
+ +Expand source code + +
@property
+def fail(self) -> Fail:
+    """`fail()` function for this request. Once a custom function's state is set to error,
+    its housing workflow will be interrupted and any provided error message will be passed
+    on to the end user through SlackBot. Additionally, any interactivity handlers associated
+    to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        def handle_button_clicks(ack, fail):
+            ack()
+            fail(error="something went wrong")
+
+        @app.function("reverse")
+        def handle_button_clicks(context):
+            context.ack()
+            context.fail(error="something went wrong")
+
+    Returns:
+        Callable `fail()` function
+    """
+    if "fail" not in self:
+        self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id)
+    return self["fail"]
+
+

fail() function for this request. Once a custom function's state is set to error, +its housing workflow will be interrupted and any provided error message will be passed +on to the end user through SlackBot. Additionally, any interactivity handlers associated +to a function invocation will no longer be invocable.

+
@app.function("reverse")
+def handle_button_clicks(ack, fail):
+    ack()
+    fail(error="something went wrong")
+
+@app.function("reverse")
+def handle_button_clicks(context):
+    context.ack()
+    context.fail(error="something went wrong")
+
+

Returns

+

Callable fail() function

+
+
prop get_thread_contextGetThreadContext | None
+
+
+ +Expand source code + +
@property
+def get_thread_context(self) -> Optional[GetThreadContext]:
+    return self.get("get_thread_context")
+
+
+
+
prop listener_runner : ThreadListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> "ThreadListenerRunner":
+    """The properly configured listener_runner that is available for middleware/listeners."""
+    return self["listener_runner"]
+
+

The properly configured listener_runner that is available for middleware/listeners.

+
+
prop respondRespond | None
+
+
+ +Expand source code + +
@property
+def respond(self) -> Optional[Respond]:
+    """`respond()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+            context.respond("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack, respond):
+            ack()
+            respond("Hi!")
+
+    Returns:
+        Callable `respond()` function
+    """
+    if "respond" not in self:
+        self["respond"] = Respond(
+            response_url=self.response_url,
+            proxy=self.client.proxy,
+            ssl=self.client.ssl,
+        )
+    return self["respond"]
+
+

respond() function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+    context.respond("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack, respond):
+    ack()
+    respond("Hi!")
+
+

Returns

+

Callable respond() function

+
+
prop save_thread_contextSaveThreadContext | None
+
+
+ +Expand source code + +
@property
+def save_thread_context(self) -> Optional[SaveThreadContext]:
+    return self.get("save_thread_context")
+
+
+
+
prop saySay
+
+
+ +Expand source code + +
@property
+def say(self) -> Say:
+    """`say()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+            context.say("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack, say):
+            ack()
+            say("Hi!")
+
+    Returns:
+        Callable `say()` function
+    """
+    if "say" not in self:
+        self["say"] = Say(client=self.client, channel=self.channel_id)
+    return self["say"]
+
+

say() function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+    context.say("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack, say):
+    ack()
+    say("Hi!")
+
+

Returns

+

Callable say() function

+
+
prop say_streamSayStream | None
+
+
+ +Expand source code + +
@property
+def say_stream(self) -> Optional[SayStream]:
+    return self.get("say_stream")
+
+
+
+
prop set_statusSetStatus | None
+
+
+ +Expand source code + +
@property
+def set_status(self) -> Optional[SetStatus]:
+    return self.get("set_status")
+
+
+
+
prop set_suggested_promptsSetSuggestedPrompts | None
+
+
+ +Expand source code + +
@property
+def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
+    return self.get("set_suggested_prompts")
+
+
+
+
prop set_titleSetTitle | None
+
+
+ +Expand source code + +
@property
+def set_title(self) -> Optional[SetTitle]:
+    return self.get("set_title")
+
+
+
+
+

Methods

+
+
+def to_copyable(self) ‑> BoltContext +
+
+
+ +Expand source code + +
def to_copyable(self) -> "BoltContext":
+    new_dict = {}
+    for prop_name, prop_value in self.items():
+        if prop_name in self.copyable_standard_property_names:
+            # all the standard properties are copiable
+            new_dict[prop_name] = prop_value
+        elif prop_name in self.non_copyable_standard_property_names:
+            # Do nothing with this property (e.g., listener_runner)
+            continue
+        else:
+            try:
+                copied_value = create_copy(prop_value)
+                new_dict[prop_name] = copied_value
+            except TypeError as te:
+                self.logger.warning(
+                    f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                    "due to a deep-copy creation error. Consider passing the value not as part of context object "
+                    f"(error: {te})"
+                )
+    return BoltContext(new_dict)
+
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/context/fail/async_fail.html b/docs/reference/context/fail/async_fail.html new file mode 100644 index 000000000..80f19d18c --- /dev/null +++ b/docs/reference/context/fail/async_fail.html @@ -0,0 +1,169 @@ + + + + + + +slack_bolt.context.fail.async_fail API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.fail.async_fail

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncFail +(client: slack_sdk.web.async_client.AsyncWebClient,
function_execution_id: str | None)
+
+
+
+ +Expand source code + +
class AsyncFail:
+    client: AsyncWebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    async def __call__(self, error: str) -> AsyncSlackResponse:
+        """Signal that the custom function failed to complete.
+
+        Kwargs:
+            error: Error message to return to slack
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("fail is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return await self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
+
+    def has_been_called(self) -> bool:
+        """Check if this fail function has been called.
+
+        Returns:
+            bool: True if the fail function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this fail function has been called.
+
+    Returns:
+        bool: True if the fail function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this fail function has been called.

+

Returns

+
+
bool
+
True if the fail function has been called, False otherwise.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/fail/fail.html b/docs/reference/context/fail/fail.html new file mode 100644 index 000000000..51f4896a4 --- /dev/null +++ b/docs/reference/context/fail/fail.html @@ -0,0 +1,169 @@ + + + + + + +slack_bolt.context.fail.fail API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.fail.fail

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Fail +(client: slack_sdk.web.client.WebClient, function_execution_id: str | None) +
+
+
+ +Expand source code + +
class Fail:
+    client: WebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: WebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    def __call__(self, error: str) -> SlackResponse:
+        """Signal that the custom function failed to complete.
+
+        Kwargs:
+            error: Error message to return to slack
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("fail is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
+
+    def has_been_called(self) -> bool:
+        """Check if this fail function has been called.
+
+        Returns:
+            bool: True if the fail function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this fail function has been called.
+
+    Returns:
+        bool: True if the fail function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this fail function has been called.

+

Returns

+
+
bool
+
True if the fail function has been called, False otherwise.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/fail/index.html b/docs/reference/context/fail/index.html new file mode 100644 index 000000000..3b35dd6aa --- /dev/null +++ b/docs/reference/context/fail/index.html @@ -0,0 +1,186 @@ + + + + + + +slack_bolt.context.fail API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.fail

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.fail.async_fail
+
+
+
+
slack_bolt.context.fail.fail
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Fail +(client: slack_sdk.web.client.WebClient, function_execution_id: str | None) +
+
+
+ +Expand source code + +
class Fail:
+    client: WebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: WebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    def __call__(self, error: str) -> SlackResponse:
+        """Signal that the custom function failed to complete.
+
+        Kwargs:
+            error: Error message to return to slack
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("fail is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
+
+    def has_been_called(self) -> bool:
+        """Check if this fail function has been called.
+
+        Returns:
+            bool: True if the fail function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this fail function has been called.
+
+    Returns:
+        bool: True if the fail function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this fail function has been called.

+

Returns

+
+
bool
+
True if the fail function has been called, False otherwise.
+
+
+
+
+
+
+
+ +
+ + + 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 new file mode 100644 index 000000000..967581b50 --- /dev/null +++ b/docs/reference/context/get_thread_context/async_get_thread_context.html @@ -0,0 +1,156 @@ + + + + + + +slack_bolt.context.get_thread_context.async_get_thread_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.get_thread_context.async_get_thread_context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncGetThreadContext +(thread_context_store: AsyncAssistantThreadContextStore,
channel_id: str,
thread_ts: str,
payload: dict)
+
+
+
+ +Expand source code + +
class AsyncGetThreadContext:
+    thread_context_store: AsyncAssistantThreadContextStore
+    payload: dict
+    channel_id: str
+    thread_ts: str
+
+    _thread_context: Optional[AssistantThreadContext]
+    thread_context_loaded: bool
+
+    def __init__(
+        self,
+        thread_context_store: AsyncAssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+        payload: dict,
+    ):
+        self.thread_context_store = thread_context_store
+        self.payload = payload
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+        self._thread_context: Optional[AssistantThreadContext] = None
+        self.thread_context_loaded = False
+
+    async def __call__(self) -> Optional[AssistantThreadContext]:
+        if self.thread_context_loaded is True:
+            return self._thread_context
+
+        thread = self.payload.get("assistant_thread")
+        if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None:
+            # assistant_thread_started
+            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:
+            # message event
+            self._thread_context = await self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts)
+
+        return self._thread_context
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var payload : dict
+
+

The type of the None singleton.

+
+
var thread_context_loaded : bool
+
+

The type of the None singleton.

+
+
var thread_context_storeAsyncAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/get_thread_context/get_thread_context.html b/docs/reference/context/get_thread_context/get_thread_context.html new file mode 100644 index 000000000..cf2e17a86 --- /dev/null +++ b/docs/reference/context/get_thread_context/get_thread_context.html @@ -0,0 +1,156 @@ + + + + + + +slack_bolt.context.get_thread_context.get_thread_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.get_thread_context.get_thread_context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class GetThreadContext +(thread_context_store: AssistantThreadContextStore,
channel_id: str,
thread_ts: str,
payload: dict)
+
+
+
+ +Expand source code + +
class GetThreadContext:
+    thread_context_store: AssistantThreadContextStore
+    payload: dict
+    channel_id: str
+    thread_ts: str
+
+    _thread_context: Optional[AssistantThreadContext]
+    thread_context_loaded: bool
+
+    def __init__(
+        self,
+        thread_context_store: AssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+        payload: dict,
+    ):
+        self.thread_context_store = thread_context_store
+        self.payload = payload
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+        self._thread_context: Optional[AssistantThreadContext] = None
+        self.thread_context_loaded = False
+
+    def __call__(self) -> Optional[AssistantThreadContext]:
+        if self.thread_context_loaded is True:
+            return self._thread_context
+
+        thread = self.payload.get("assistant_thread")
+        if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None:
+            # assistant_thread_started
+            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:
+            # message event
+            self._thread_context = self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts)
+
+        return self._thread_context
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var payload : dict
+
+

The type of the None singleton.

+
+
var thread_context_loaded : bool
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/get_thread_context/index.html b/docs/reference/context/get_thread_context/index.html new file mode 100644 index 000000000..5f9e38e71 --- /dev/null +++ b/docs/reference/context/get_thread_context/index.html @@ -0,0 +1,173 @@ + + + + + + +slack_bolt.context.get_thread_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.get_thread_context

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.get_thread_context.async_get_thread_context
+
+
+
+
slack_bolt.context.get_thread_context.get_thread_context
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class GetThreadContext +(thread_context_store: AssistantThreadContextStore,
channel_id: str,
thread_ts: str,
payload: dict)
+
+
+
+ +Expand source code + +
class GetThreadContext:
+    thread_context_store: AssistantThreadContextStore
+    payload: dict
+    channel_id: str
+    thread_ts: str
+
+    _thread_context: Optional[AssistantThreadContext]
+    thread_context_loaded: bool
+
+    def __init__(
+        self,
+        thread_context_store: AssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+        payload: dict,
+    ):
+        self.thread_context_store = thread_context_store
+        self.payload = payload
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+        self._thread_context: Optional[AssistantThreadContext] = None
+        self.thread_context_loaded = False
+
+    def __call__(self) -> Optional[AssistantThreadContext]:
+        if self.thread_context_loaded is True:
+            return self._thread_context
+
+        thread = self.payload.get("assistant_thread")
+        if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None:
+            # assistant_thread_started
+            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:
+            # message event
+            self._thread_context = self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts)
+
+        return self._thread_context
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var payload : dict
+
+

The type of the None singleton.

+
+
var thread_context_loaded : bool
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/index.html b/docs/reference/context/index.html new file mode 100644 index 000000000..ebdfe8aa8 --- /dev/null +++ b/docs/reference/context/index.html @@ -0,0 +1,818 @@ + + + + + + +slack_bolt.context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context

+
+
+

All listeners have access to a context dictionary, which can be used to enrich events with additional information. +Bolt automatically attaches information that is included in the incoming event, +like user_id, team_id, channel_id, and enterprise_id.

+

Refer to https://docs.slack.dev/tools/bolt-python/concepts/context for details.

+
+
+

Sub-modules

+
+
slack_bolt.context.ack
+
+
+
+
slack_bolt.context.assistant
+
+
+
+
slack_bolt.context.async_context
+
+
+
+
slack_bolt.context.base_context
+
+
+
+
slack_bolt.context.complete
+
+
+
+
slack_bolt.context.context
+
+
+
+
slack_bolt.context.fail
+
+
+
+
slack_bolt.context.get_thread_context
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BoltContext +(*args, **kwargs) +
+
+
+ +Expand source code + +
class BoltContext(BaseContext):
+    """Context object associated with a request from Slack."""
+
+    def to_copyable(self) -> "BoltContext":
+        new_dict = {}
+        for prop_name, prop_value in self.items():
+            if prop_name in self.copyable_standard_property_names:
+                # all the standard properties are copiable
+                new_dict[prop_name] = prop_value
+            elif prop_name in self.non_copyable_standard_property_names:
+                # Do nothing with this property (e.g., listener_runner)
+                continue
+            else:
+                try:
+                    copied_value = create_copy(prop_value)
+                    new_dict[prop_name] = copied_value
+                except TypeError as te:
+                    self.logger.warning(
+                        f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                        "due to a deep-copy creation error. Consider passing the value not as part of context object "
+                        f"(error: {te})"
+                    )
+        return BoltContext(new_dict)
+
+    # The return type is intentionally string to avoid circular imports
+    @property
+    def listener_runner(self) -> "ThreadListenerRunner":
+        """The properly configured listener_runner that is available for middleware/listeners."""
+        return self["listener_runner"]
+
+    @property
+    def client(self) -> WebClient:
+        """The `WebClient` instance available for this request.
+
+            @app.event("app_mention")
+            def handle_events(context):
+                context.client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+            # You can access "client" this way too.
+            @app.event("app_mention")
+            def handle_events(client, context):
+                client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+        Returns:
+            `WebClient` instance
+        """
+        if "client" not in self:
+            self["client"] = WebClient(token=None)
+        return self["client"]
+
+    @property
+    def ack(self) -> Ack:
+        """`ack()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack):
+                ack()
+
+        Returns:
+            Callable `ack()` function
+        """
+        if "ack" not in self:
+            self["ack"] = Ack()
+        return self["ack"]
+
+    @property
+    def say(self) -> Say:
+        """`say()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+                context.say("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack, say):
+                ack()
+                say("Hi!")
+
+        Returns:
+            Callable `say()` function
+        """
+        if "say" not in self:
+            self["say"] = Say(client=self.client, channel=self.channel_id)
+        return self["say"]
+
+    @property
+    def respond(self) -> Optional[Respond]:
+        """`respond()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+                context.respond("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack, respond):
+                ack()
+                respond("Hi!")
+
+        Returns:
+            Callable `respond()` function
+        """
+        if "respond" not in self:
+            self["respond"] = Respond(
+                response_url=self.response_url,
+                proxy=self.client.proxy,
+                ssl=self.client.ssl,
+            )
+        return self["respond"]
+
+    @property
+    def complete(self) -> Complete:
+        """`complete()` function for this request. Once a custom function's state is set to complete,
+        any outputs the function returns will be passed along to the next step of its housing workflow,
+        or complete the workflow if the function is the last step in a workflow. Additionally,
+        any interactivity handlers associated to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            def handle_button_clicks(ack, complete):
+                ack()
+                complete(outputs={"stringReverse":"olleh"})
+
+            @app.function("reverse")
+            def handle_button_clicks(context):
+                context.ack()
+                context.complete(outputs={"stringReverse":"olleh"})
+
+        Returns:
+            Callable `complete()` function
+        """
+        if "complete" not in self:
+            self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id)
+        return self["complete"]
+
+    @property
+    def fail(self) -> Fail:
+        """`fail()` function for this request. Once a custom function's state is set to error,
+        its housing workflow will be interrupted and any provided error message will be passed
+        on to the end user through SlackBot. Additionally, any interactivity handlers associated
+        to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            def handle_button_clicks(ack, fail):
+                ack()
+                fail(error="something went wrong")
+
+            @app.function("reverse")
+            def handle_button_clicks(context):
+                context.ack()
+                context.fail(error="something went wrong")
+
+        Returns:
+            Callable `fail()` function
+        """
+        if "fail" not in self:
+            self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id)
+        return self["fail"]
+
+    @property
+    def set_title(self) -> Optional[SetTitle]:
+        return self.get("set_title")
+
+    @property
+    def set_status(self) -> Optional[SetStatus]:
+        return self.get("set_status")
+
+    @property
+    def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
+        return self.get("set_suggested_prompts")
+
+    @property
+    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")
+
+

Context object associated with a request from Slack.

+

Ancestors

+ +

Instance variables

+
+
prop ackAck
+
+
+ +Expand source code + +
@property
+def ack(self) -> Ack:
+    """`ack()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack):
+            ack()
+
+    Returns:
+        Callable `ack()` function
+    """
+    if "ack" not in self:
+        self["ack"] = Ack()
+    return self["ack"]
+
+

slack_bolt.context.ack function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack):
+    ack()
+
+

Returns

+

Callable slack_bolt.context.ack function

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    """The `WebClient` instance available for this request.
+
+        @app.event("app_mention")
+        def handle_events(context):
+            context.client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+        # You can access "client" this way too.
+        @app.event("app_mention")
+        def handle_events(client, context):
+            client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+    Returns:
+        `WebClient` instance
+    """
+    if "client" not in self:
+        self["client"] = WebClient(token=None)
+    return self["client"]
+
+

The WebClient instance available for this request.

+
@app.event("app_mention")
+def handle_events(context):
+    context.client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+# You can access "client" this way too.
+@app.event("app_mention")
+def handle_events(client, context):
+    client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+

Returns

+

WebClient instance

+
+
prop completeComplete
+
+
+ +Expand source code + +
@property
+def complete(self) -> Complete:
+    """`complete()` function for this request. Once a custom function's state is set to complete,
+    any outputs the function returns will be passed along to the next step of its housing workflow,
+    or complete the workflow if the function is the last step in a workflow. Additionally,
+    any interactivity handlers associated to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        def handle_button_clicks(ack, complete):
+            ack()
+            complete(outputs={"stringReverse":"olleh"})
+
+        @app.function("reverse")
+        def handle_button_clicks(context):
+            context.ack()
+            context.complete(outputs={"stringReverse":"olleh"})
+
+    Returns:
+        Callable `complete()` function
+    """
+    if "complete" not in self:
+        self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id)
+    return self["complete"]
+
+

slack_bolt.context.complete function for this request. Once a custom function's state is set to complete, +any outputs the function returns will be passed along to the next step of its housing workflow, +or complete the workflow if the function is the last step in a workflow. Additionally, +any interactivity handlers associated to a function invocation will no longer be invocable.

+
@app.function("reverse")
+def handle_button_clicks(ack, complete):
+    ack()
+    complete(outputs={"stringReverse":"olleh"})
+
+@app.function("reverse")
+def handle_button_clicks(context):
+    context.ack()
+    context.complete(outputs={"stringReverse":"olleh"})
+
+

Returns

+

Callable slack_bolt.context.complete function

+
+
prop failFail
+
+
+ +Expand source code + +
@property
+def fail(self) -> Fail:
+    """`fail()` function for this request. Once a custom function's state is set to error,
+    its housing workflow will be interrupted and any provided error message will be passed
+    on to the end user through SlackBot. Additionally, any interactivity handlers associated
+    to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        def handle_button_clicks(ack, fail):
+            ack()
+            fail(error="something went wrong")
+
+        @app.function("reverse")
+        def handle_button_clicks(context):
+            context.ack()
+            context.fail(error="something went wrong")
+
+    Returns:
+        Callable `fail()` function
+    """
+    if "fail" not in self:
+        self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id)
+    return self["fail"]
+
+

slack_bolt.context.fail function for this request. Once a custom function's state is set to error, +its housing workflow will be interrupted and any provided error message will be passed +on to the end user through SlackBot. Additionally, any interactivity handlers associated +to a function invocation will no longer be invocable.

+
@app.function("reverse")
+def handle_button_clicks(ack, fail):
+    ack()
+    fail(error="something went wrong")
+
+@app.function("reverse")
+def handle_button_clicks(context):
+    context.ack()
+    context.fail(error="something went wrong")
+
+

Returns

+

Callable slack_bolt.context.fail function

+
+
prop get_thread_contextGetThreadContext | None
+
+
+ +Expand source code + +
@property
+def get_thread_context(self) -> Optional[GetThreadContext]:
+    return self.get("get_thread_context")
+
+
+
+
prop listener_runner : ThreadListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> "ThreadListenerRunner":
+    """The properly configured listener_runner that is available for middleware/listeners."""
+    return self["listener_runner"]
+
+

The properly configured listener_runner that is available for middleware/listeners.

+
+
prop respondRespond | None
+
+
+ +Expand source code + +
@property
+def respond(self) -> Optional[Respond]:
+    """`respond()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+            context.respond("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack, respond):
+            ack()
+            respond("Hi!")
+
+    Returns:
+        Callable `respond()` function
+    """
+    if "respond" not in self:
+        self["respond"] = Respond(
+            response_url=self.response_url,
+            proxy=self.client.proxy,
+            ssl=self.client.ssl,
+        )
+    return self["respond"]
+
+

slack_bolt.context.respond function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+    context.respond("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack, respond):
+    ack()
+    respond("Hi!")
+
+

Returns

+

Callable slack_bolt.context.respond function

+
+
prop save_thread_contextSaveThreadContext | None
+
+
+ +Expand source code + +
@property
+def save_thread_context(self) -> Optional[SaveThreadContext]:
+    return self.get("save_thread_context")
+
+
+
+
prop saySay
+
+
+ +Expand source code + +
@property
+def say(self) -> Say:
+    """`say()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+            context.say("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack, say):
+            ack()
+            say("Hi!")
+
+    Returns:
+        Callable `say()` function
+    """
+    if "say" not in self:
+        self["say"] = Say(client=self.client, channel=self.channel_id)
+    return self["say"]
+
+

slack_bolt.context.say function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+    context.say("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack, say):
+    ack()
+    say("Hi!")
+
+

Returns

+

Callable slack_bolt.context.say function

+
+
prop say_streamSayStream | None
+
+
+ +Expand source code + +
@property
+def say_stream(self) -> Optional[SayStream]:
+    return self.get("say_stream")
+
+
+
+
prop set_statusSetStatus | None
+
+
+ +Expand source code + +
@property
+def set_status(self) -> Optional[SetStatus]:
+    return self.get("set_status")
+
+
+
+
prop set_suggested_promptsSetSuggestedPrompts | None
+
+
+ +Expand source code + +
@property
+def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
+    return self.get("set_suggested_prompts")
+
+
+
+
prop set_titleSetTitle | None
+
+
+ +Expand source code + +
@property
+def set_title(self) -> Optional[SetTitle]:
+    return self.get("set_title")
+
+
+
+
+

Methods

+
+
+def to_copyable(self) ‑> BoltContext +
+
+
+ +Expand source code + +
def to_copyable(self) -> "BoltContext":
+    new_dict = {}
+    for prop_name, prop_value in self.items():
+        if prop_name in self.copyable_standard_property_names:
+            # all the standard properties are copiable
+            new_dict[prop_name] = prop_value
+        elif prop_name in self.non_copyable_standard_property_names:
+            # Do nothing with this property (e.g., listener_runner)
+            continue
+        else:
+            try:
+                copied_value = create_copy(prop_value)
+                new_dict[prop_name] = copied_value
+            except TypeError as te:
+                self.logger.warning(
+                    f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                    "due to a deep-copy creation error. Consider passing the value not as part of context object "
+                    f"(error: {te})"
+                )
+    return BoltContext(new_dict)
+
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/context/respond/async_respond.html b/docs/reference/context/respond/async_respond.html new file mode 100644 index 000000000..ed071afaf --- /dev/null +++ b/docs/reference/context/respond/async_respond.html @@ -0,0 +1,166 @@ + + + + + + +slack_bolt.context.respond.async_respond API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.respond.async_respond

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncRespond +(*,
response_url: str | None,
proxy: str | None = None,
ssl: ssl.SSLContext | None = None)
+
+
+
+ +Expand source code + +
class AsyncRespond:
+    response_url: Optional[str]
+    proxy: Optional[str]
+    ssl: Optional[SSLContext]
+
+    def __init__(
+        self,
+        *,
+        response_url: Optional[str],
+        proxy: Optional[str] = None,
+        ssl: Optional[SSLContext] = None,
+    ):
+        self.response_url = response_url
+        self.proxy = proxy
+        self.ssl = ssl
+
+    async def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        response_type: Optional[str] = None,
+        replace_original: Optional[bool] = None,
+        delete_original: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> WebhookResponse:
+        if self.response_url is not None:
+            client = AsyncWebhookClient(
+                url=self.response_url,
+                proxy=self.proxy,
+                ssl=self.ssl,
+            )
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                message = _build_message(
+                    text=text,  # type: ignore[arg-type]
+                    blocks=blocks,
+                    attachments=attachments,
+                    response_type=response_type,
+                    replace_original=replace_original,
+                    delete_original=delete_original,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    thread_ts=thread_ts,
+                    metadata=metadata,
+                )
+                return await client.send_dict(message)
+            elif isinstance(text_or_whole_response, dict):
+                whole_response: dict = text_or_whole_response
+                message = _build_message(**whole_response)
+                return await client.send_dict(message)
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text)})")
+        else:
+            raise ValueError("respond is unsupported here as there is no response_url")
+
+
+

Class variables

+
+
var proxy : str | None
+
+

The type of the None singleton.

+
+
var response_url : str | None
+
+

The type of the None singleton.

+
+
var ssl : ssl.SSLContext | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/respond/index.html b/docs/reference/context/respond/index.html new file mode 100644 index 000000000..8c116c956 --- /dev/null +++ b/docs/reference/context/respond/index.html @@ -0,0 +1,188 @@ + + + + + + +slack_bolt.context.respond API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.respond

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.respond.async_respond
+
+
+
+
slack_bolt.context.respond.internals
+
+
+
+
slack_bolt.context.respond.respond
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Respond +(*,
response_url: str | None,
proxy: str | None = None,
ssl: ssl.SSLContext | None = None)
+
+
+
+ +Expand source code + +
class Respond:
+    response_url: Optional[str]
+    proxy: Optional[str]
+    ssl: Optional[SSLContext]
+
+    def __init__(
+        self,
+        *,
+        response_url: Optional[str],
+        proxy: Optional[str] = None,
+        ssl: Optional[SSLContext] = None,
+    ):
+        self.response_url = response_url
+        self.proxy = proxy
+        self.ssl = ssl
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        response_type: Optional[str] = None,
+        replace_original: Optional[bool] = None,
+        delete_original: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> WebhookResponse:
+        if self.response_url is not None:
+            client = WebhookClient(
+                url=self.response_url,
+                proxy=self.proxy,
+                ssl=self.ssl,
+            )
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                message = _build_message(
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    response_type=response_type,
+                    replace_original=replace_original,
+                    delete_original=delete_original,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    thread_ts=thread_ts,
+                    metadata=metadata,
+                )
+                return client.send_dict(message)
+            elif isinstance(text_or_whole_response, dict):
+                message = _build_message(**text_or_whole_response)
+                return client.send_dict(message)
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("respond is unsupported here as there is no response_url")
+
+
+

Class variables

+
+
var proxy : str | None
+
+

The type of the None singleton.

+
+
var response_url : str | None
+
+

The type of the None singleton.

+
+
var ssl : ssl.SSLContext | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/respond/internals.html b/docs/reference/context/respond/internals.html new file mode 100644 index 000000000..e61988ef6 --- /dev/null +++ b/docs/reference/context/respond/internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.context.respond.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.respond.internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/respond/respond.html b/docs/reference/context/respond/respond.html new file mode 100644 index 000000000..af2271eb6 --- /dev/null +++ b/docs/reference/context/respond/respond.html @@ -0,0 +1,166 @@ + + + + + + +slack_bolt.context.respond.respond API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.respond.respond

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Respond +(*,
response_url: str | None,
proxy: str | None = None,
ssl: ssl.SSLContext | None = None)
+
+
+
+ +Expand source code + +
class Respond:
+    response_url: Optional[str]
+    proxy: Optional[str]
+    ssl: Optional[SSLContext]
+
+    def __init__(
+        self,
+        *,
+        response_url: Optional[str],
+        proxy: Optional[str] = None,
+        ssl: Optional[SSLContext] = None,
+    ):
+        self.response_url = response_url
+        self.proxy = proxy
+        self.ssl = ssl
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        response_type: Optional[str] = None,
+        replace_original: Optional[bool] = None,
+        delete_original: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> WebhookResponse:
+        if self.response_url is not None:
+            client = WebhookClient(
+                url=self.response_url,
+                proxy=self.proxy,
+                ssl=self.ssl,
+            )
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                message = _build_message(
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    response_type=response_type,
+                    replace_original=replace_original,
+                    delete_original=delete_original,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    thread_ts=thread_ts,
+                    metadata=metadata,
+                )
+                return client.send_dict(message)
+            elif isinstance(text_or_whole_response, dict):
+                message = _build_message(**text_or_whole_response)
+                return client.send_dict(message)
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("respond is unsupported here as there is no response_url")
+
+
+

Class variables

+
+
var proxy : str | None
+
+

The type of the None singleton.

+
+
var response_url : str | None
+
+

The type of the None singleton.

+
+
var ssl : ssl.SSLContext | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/save_thread_context/async_save_thread_context.html b/docs/reference/context/save_thread_context/async_save_thread_context.html new file mode 100644 index 000000000..f57291c3c --- /dev/null +++ b/docs/reference/context/save_thread_context/async_save_thread_context.html @@ -0,0 +1,129 @@ + + + + + + +slack_bolt.context.save_thread_context.async_save_thread_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.save_thread_context.async_save_thread_context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSaveThreadContext +(thread_context_store: AsyncAssistantThreadContextStore,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class AsyncSaveThreadContext:
+    thread_context_store: AsyncAssistantThreadContextStore
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        thread_context_store: AsyncAssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.thread_context_store = thread_context_store
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    async def __call__(self, new_context: Dict[str, str]) -> None:
+        await self.thread_context_store.save(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            context=new_context,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var thread_context_storeAsyncAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/save_thread_context/index.html b/docs/reference/context/save_thread_context/index.html new file mode 100644 index 000000000..01f63ecd8 --- /dev/null +++ b/docs/reference/context/save_thread_context/index.html @@ -0,0 +1,146 @@ + + + + + + +slack_bolt.context.save_thread_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.save_thread_context

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.save_thread_context.async_save_thread_context
+
+
+
+
slack_bolt.context.save_thread_context.save_thread_context
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SaveThreadContext +(thread_context_store: AssistantThreadContextStore,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class SaveThreadContext:
+    thread_context_store: AssistantThreadContextStore
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        thread_context_store: AssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.thread_context_store = thread_context_store
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    def __call__(self, new_context: Dict[str, str]) -> None:
+        self.thread_context_store.save(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            context=new_context,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/save_thread_context/save_thread_context.html b/docs/reference/context/save_thread_context/save_thread_context.html new file mode 100644 index 000000000..328441034 --- /dev/null +++ b/docs/reference/context/save_thread_context/save_thread_context.html @@ -0,0 +1,129 @@ + + + + + + +slack_bolt.context.save_thread_context.save_thread_context API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.save_thread_context.save_thread_context

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SaveThreadContext +(thread_context_store: AssistantThreadContextStore,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class SaveThreadContext:
+    thread_context_store: AssistantThreadContextStore
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        thread_context_store: AssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.thread_context_store = thread_context_store
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    def __call__(self, new_context: Dict[str, str]) -> None:
+        self.thread_context_store.save(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            context=new_context,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/say/async_say.html b/docs/reference/context/say/async_say.html new file mode 100644 index 000000000..e170251fe --- /dev/null +++ b/docs/reference/context/say/async_say.html @@ -0,0 +1,191 @@ + + + + + + +slack_bolt.context.say.async_say API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.say.async_say

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSay +(client: slack_sdk.web.async_client.AsyncWebClient | None,
channel: str | None,
thread_ts: str | None = None,
build_metadata: Callable[[], Awaitable[Dict | slack_sdk.models.metadata.Metadata]] | None = None)
+
+
+
+ +Expand source code + +
class AsyncSay:
+    client: Optional[AsyncWebClient]
+    channel: Optional[str]
+    thread_ts: Optional[str]
+    build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]]
+
+    def __init__(
+        self,
+        client: Optional[AsyncWebClient],
+        channel: Optional[str],
+        thread_ts: Optional[str] = None,
+        build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]] = None,
+    ):
+        self.client = client
+        self.channel = channel
+        self.thread_ts = thread_ts
+        self.build_metadata = build_metadata
+
+    async def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[Dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[Dict, Attachment]]] = None,
+        channel: Optional[str] = None,
+        as_user: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        reply_broadcast: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        icon_emoji: Optional[str] = None,
+        icon_url: Optional[str] = None,
+        username: Optional[str] = None,
+        markdown_text: Optional[str] = None,
+        mrkdwn: Optional[bool] = None,
+        link_names: Optional[bool] = None,
+        parse: Optional[str] = None,  # none, full
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        **kwargs,
+    ) -> AsyncSlackResponse:
+        if _can_say(self, channel):
+            if metadata is None and self.build_metadata is not None:
+                metadata = await self.build_metadata()
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                return await self.client.chat_postMessage(  # type: ignore[union-attr]
+                    channel=channel or self.channel,  # type: ignore[arg-type]
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    as_user=as_user,
+                    thread_ts=thread_ts or self.thread_ts,
+                    reply_broadcast=reply_broadcast,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    icon_emoji=icon_emoji,
+                    icon_url=icon_url,
+                    username=username,
+                    markdown_text=markdown_text,
+                    mrkdwn=mrkdwn,
+                    link_names=link_names,
+                    parse=parse,
+                    metadata=metadata,
+                    **kwargs,
+                )
+            elif isinstance(text_or_whole_response, dict):
+                message: dict = create_copy(text_or_whole_response)
+                if "channel" not in message:
+                    message["channel"] = channel or self.channel
+                if "thread_ts" not in message:
+                    message["thread_ts"] = thread_ts or self.thread_ts
+                if "metadata" not in message:
+                    message["metadata"] = metadata
+                return await self.client.chat_postMessage(**message)  # type: ignore[union-attr]
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("say without channel_id here is unsupported")
+
+
+

Class variables

+
+
var build_metadata : Callable[[], Awaitable[Dict | slack_sdk.models.metadata.Metadata]] | None
+
+

The type of the None singleton.

+
+
var channel : str | None
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient | 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/index.html b/docs/reference/context/say/index.html new file mode 100644 index 000000000..e2ed0d03f --- /dev/null +++ b/docs/reference/context/say/index.html @@ -0,0 +1,222 @@ + + + + + + +slack_bolt.context.say API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.say

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.say.async_say
+
+
+
+
slack_bolt.context.say.internals
+
+
+
+
slack_bolt.context.say.say
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Say +(client: slack_sdk.web.client.WebClient | None,
channel: str | None,
thread_ts: str | None = None,
metadata: Dict | slack_sdk.models.metadata.Metadata | None = None,
build_metadata: Callable[[], Dict | slack_sdk.models.metadata.Metadata | None] | None = None)
+
+
+
+ +Expand source code + +
class Say:
+    client: Optional[WebClient]
+    channel: Optional[str]
+    thread_ts: Optional[str]
+    metadata: Optional[Union[Dict, Metadata]]
+    build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]]
+
+    def __init__(
+        self,
+        client: Optional[WebClient],
+        channel: Optional[str],
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]] = None,
+    ):
+        self.client = client
+        self.channel = channel
+        self.thread_ts = thread_ts
+        self.metadata = metadata
+        self.build_metadata = build_metadata
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[Dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[Dict, Attachment]]] = None,
+        channel: Optional[str] = None,
+        as_user: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        reply_broadcast: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        icon_emoji: Optional[str] = None,
+        icon_url: Optional[str] = None,
+        username: Optional[str] = None,
+        markdown_text: Optional[str] = None,
+        mrkdwn: Optional[bool] = None,
+        link_names: Optional[bool] = None,
+        parse: Optional[str] = None,  # none, full
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        **kwargs,
+    ) -> SlackResponse:
+        if _can_say(self, channel):
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                if metadata is None:
+                    metadata = self.build_metadata() if self.build_metadata is not None else self.metadata
+                return self.client.chat_postMessage(  # type: ignore[union-attr]
+                    channel=channel or self.channel,  # type: ignore[arg-type]
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    as_user=as_user,
+                    thread_ts=thread_ts or self.thread_ts,
+                    reply_broadcast=reply_broadcast,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    icon_emoji=icon_emoji,
+                    icon_url=icon_url,
+                    username=username,
+                    markdown_text=markdown_text,
+                    mrkdwn=mrkdwn,
+                    link_names=link_names,
+                    parse=parse,
+                    metadata=metadata,
+                    **kwargs,
+                )
+            elif isinstance(text_or_whole_response, dict):
+                message: dict = create_copy(text_or_whole_response)
+                if "channel" not in message:
+                    message["channel"] = channel or self.channel
+                if "thread_ts" not in message:
+                    message["thread_ts"] = thread_ts or self.thread_ts
+                if "metadata" not in message:
+                    metadata = self.build_metadata() if self.build_metadata is not None else self.metadata
+                    message["metadata"] = metadata
+                return self.client.chat_postMessage(**message)  # type: ignore[union-attr]
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("say without channel_id here is unsupported")
+
+
+

Class variables

+
+
var build_metadata : Callable[[], Dict | slack_sdk.models.metadata.Metadata | None] | None
+
+

The type of the None singleton.

+
+
var channel : str | None
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient | None
+
+

The type of the None singleton.

+
+
var metadata : Dict | slack_sdk.models.metadata.Metadata | 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/internals.html b/docs/reference/context/say/internals.html new file mode 100644 index 000000000..861065203 --- /dev/null +++ b/docs/reference/context/say/internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.context.say.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.say.internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/say/say.html b/docs/reference/context/say/say.html new file mode 100644 index 000000000..c66e2776f --- /dev/null +++ b/docs/reference/context/say/say.html @@ -0,0 +1,200 @@ + + + + + + +slack_bolt.context.say.say API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.say.say

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Say +(client: slack_sdk.web.client.WebClient | None,
channel: str | None,
thread_ts: str | None = None,
metadata: Dict | slack_sdk.models.metadata.Metadata | None = None,
build_metadata: Callable[[], Dict | slack_sdk.models.metadata.Metadata | None] | None = None)
+
+
+
+ +Expand source code + +
class Say:
+    client: Optional[WebClient]
+    channel: Optional[str]
+    thread_ts: Optional[str]
+    metadata: Optional[Union[Dict, Metadata]]
+    build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]]
+
+    def __init__(
+        self,
+        client: Optional[WebClient],
+        channel: Optional[str],
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]] = None,
+    ):
+        self.client = client
+        self.channel = channel
+        self.thread_ts = thread_ts
+        self.metadata = metadata
+        self.build_metadata = build_metadata
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[Dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[Dict, Attachment]]] = None,
+        channel: Optional[str] = None,
+        as_user: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        reply_broadcast: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        icon_emoji: Optional[str] = None,
+        icon_url: Optional[str] = None,
+        username: Optional[str] = None,
+        markdown_text: Optional[str] = None,
+        mrkdwn: Optional[bool] = None,
+        link_names: Optional[bool] = None,
+        parse: Optional[str] = None,  # none, full
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        **kwargs,
+    ) -> SlackResponse:
+        if _can_say(self, channel):
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                if metadata is None:
+                    metadata = self.build_metadata() if self.build_metadata is not None else self.metadata
+                return self.client.chat_postMessage(  # type: ignore[union-attr]
+                    channel=channel or self.channel,  # type: ignore[arg-type]
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    as_user=as_user,
+                    thread_ts=thread_ts or self.thread_ts,
+                    reply_broadcast=reply_broadcast,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    icon_emoji=icon_emoji,
+                    icon_url=icon_url,
+                    username=username,
+                    markdown_text=markdown_text,
+                    mrkdwn=mrkdwn,
+                    link_names=link_names,
+                    parse=parse,
+                    metadata=metadata,
+                    **kwargs,
+                )
+            elif isinstance(text_or_whole_response, dict):
+                message: dict = create_copy(text_or_whole_response)
+                if "channel" not in message:
+                    message["channel"] = channel or self.channel
+                if "thread_ts" not in message:
+                    message["thread_ts"] = thread_ts or self.thread_ts
+                if "metadata" not in message:
+                    metadata = self.build_metadata() if self.build_metadata is not None else self.metadata
+                    message["metadata"] = metadata
+                return self.client.chat_postMessage(**message)  # type: ignore[union-attr]
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("say without channel_id here is unsupported")
+
+
+

Class variables

+
+
var build_metadata : Callable[[], Dict | slack_sdk.models.metadata.Metadata | None] | None
+
+

The type of the None singleton.

+
+
var channel : str | None
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient | None
+
+

The type of the None singleton.

+
+
var metadata : Dict | slack_sdk.models.metadata.Metadata | 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/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 new file mode 100644 index 000000000..770583e4a --- /dev/null +++ b/docs/reference/context/set_status/async_set_status.html @@ -0,0 +1,142 @@ + + + + + + +slack_bolt.context.set_status.async_set_status API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_status.async_set_status

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSetStatus +(client: slack_sdk.web.async_client.AsyncWebClient,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class AsyncSetStatus:
+    client: AsyncWebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            status=status,
+            loading_messages=loading_messages,
+            icon_emoji=icon_emoji,
+            icon_url=icon_url,
+            username=username,
+            **kwargs,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/set_status/index.html b/docs/reference/context/set_status/index.html new file mode 100644 index 000000000..380e37f4f --- /dev/null +++ b/docs/reference/context/set_status/index.html @@ -0,0 +1,159 @@ + + + + + + +slack_bolt.context.set_status API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_status

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.set_status.async_set_status
+
+
+
+
slack_bolt.context.set_status.set_status
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SetStatus +(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +
+
+
+ +Expand source code + +
class SetStatus:
+    client: WebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            status=status,
+            loading_messages=loading_messages,
+            icon_emoji=icon_emoji,
+            icon_url=icon_url,
+            username=username,
+            **kwargs,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/set_status/set_status.html b/docs/reference/context/set_status/set_status.html new file mode 100644 index 000000000..b0a0a9ee7 --- /dev/null +++ b/docs/reference/context/set_status/set_status.html @@ -0,0 +1,142 @@ + + + + + + +slack_bolt.context.set_status.set_status API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_status.set_status

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SetStatus +(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +
+
+
+ +Expand source code + +
class SetStatus:
+    client: WebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            status=status,
+            loading_messages=loading_messages,
+            icon_emoji=icon_emoji,
+            icon_url=icon_url,
+            username=username,
+            **kwargs,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + 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 new file mode 100644 index 000000000..1c7656456 --- /dev/null +++ b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html @@ -0,0 +1,142 @@ + + + + + + +slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSetSuggestedPrompts +(client: slack_sdk.web.async_client.AsyncWebClient,
channel_id: str,
thread_ts: str | None = None)
+
+
+
+ +Expand source code + +
class AsyncSetSuggestedPrompts:
+    client: AsyncWebClient
+    channel_id: str
+    thread_ts: Optional[str]
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        channel_id: str,
+        thread_ts: Optional[str] = None,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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:
+            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=self.channel_id,
+            thread_ts=thread_ts if thread_ts is not None else self.thread_ts,
+            prompts=prompts_arg,
+            title=title,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
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 new file mode 100644 index 000000000..cf606ae2f --- /dev/null +++ b/docs/reference/context/set_suggested_prompts/index.html @@ -0,0 +1,159 @@ + + + + + + +slack_bolt.context.set_suggested_prompts API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_suggested_prompts

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts
+
+
+
+
slack_bolt.context.set_suggested_prompts.set_suggested_prompts
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SetSuggestedPrompts +(client: slack_sdk.web.client.WebClient,
channel_id: str,
thread_ts: str | None = None)
+
+
+
+ +Expand source code + +
class SetSuggestedPrompts:
+    client: WebClient
+    channel_id: str
+    thread_ts: Optional[str]
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: Optional[str] = None,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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:
+            if isinstance(prompt, str):
+                prompts_arg.append({"title": prompt, "message": prompt})
+            else:
+                prompts_arg.append(prompt)
+
+        return self.client.assistant_threads_setSuggestedPrompts(
+            channel_id=self.channel_id,
+            thread_ts=thread_ts if thread_ts is not None else self.thread_ts,
+            prompts=prompts_arg,
+            title=title,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
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 new file mode 100644 index 000000000..f034fc677 --- /dev/null +++ b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html @@ -0,0 +1,142 @@ + + + + + + +slack_bolt.context.set_suggested_prompts.set_suggested_prompts API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_suggested_prompts.set_suggested_prompts

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SetSuggestedPrompts +(client: slack_sdk.web.client.WebClient,
channel_id: str,
thread_ts: str | None = None)
+
+
+
+ +Expand source code + +
class SetSuggestedPrompts:
+    client: WebClient
+    channel_id: str
+    thread_ts: Optional[str]
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: Optional[str] = None,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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:
+            if isinstance(prompt, str):
+                prompts_arg.append({"title": prompt, "message": prompt})
+            else:
+                prompts_arg.append(prompt)
+
+        return self.client.assistant_threads_setSuggestedPrompts(
+            channel_id=self.channel_id,
+            thread_ts=thread_ts if thread_ts is not None else self.thread_ts,
+            prompts=prompts_arg,
+            title=title,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str | None
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/set_title/async_set_title.html b/docs/reference/context/set_title/async_set_title.html new file mode 100644 index 000000000..e7db1ca1c --- /dev/null +++ b/docs/reference/context/set_title/async_set_title.html @@ -0,0 +1,129 @@ + + + + + + +slack_bolt.context.set_title.async_set_title API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_title.async_set_title

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSetTitle +(client: slack_sdk.web.async_client.AsyncWebClient,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class AsyncSetTitle:
+    client: AsyncWebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: AsyncWebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    async def __call__(self, title: str) -> AsyncSlackResponse:
+        return await self.client.assistant_threads_setTitle(
+            title=title,
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/set_title/index.html b/docs/reference/context/set_title/index.html new file mode 100644 index 000000000..7ae070fe8 --- /dev/null +++ b/docs/reference/context/set_title/index.html @@ -0,0 +1,146 @@ + + + + + + +slack_bolt.context.set_title API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_title

+
+
+
+
+

Sub-modules

+
+
slack_bolt.context.set_title.async_set_title
+
+
+
+
slack_bolt.context.set_title.set_title
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SetTitle +(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +
+
+
+ +Expand source code + +
class SetTitle:
+    client: WebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    def __call__(self, title: str) -> SlackResponse:
+        return self.client.assistant_threads_setTitle(
+            title=title,
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/context/set_title/set_title.html b/docs/reference/context/set_title/set_title.html new file mode 100644 index 000000000..cd4d1e27e --- /dev/null +++ b/docs/reference/context/set_title/set_title.html @@ -0,0 +1,129 @@ + + + + + + +slack_bolt.context.set_title.set_title API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.context.set_title.set_title

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SetTitle +(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +
+
+
+ +Expand source code + +
class SetTitle:
+    client: WebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    def __call__(self, title: str) -> SlackResponse:
+        return self.client.assistant_threads_setTitle(
+            title=title,
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/error/index.html b/docs/reference/error/index.html new file mode 100644 index 000000000..9a9998e63 --- /dev/null +++ b/docs/reference/error/index.html @@ -0,0 +1,166 @@ + + + + + + +slack_bolt.error API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.error

+
+
+

Bolt specific error types.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class BoltError +(*args, **kwargs) +
+
+
+ +Expand source code + +
class BoltError(Exception):
+    """General class in a Bolt app"""
+
+

General class in a Bolt app

+

Ancestors

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

Subclasses

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

General class in a Bolt app

+

Ancestors

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

Class variables

+
+
var body : dict
+
+

The type of the None singleton.

+
+
var current_response : BoltResponse | None
+
+

The type of the None singleton.

+
+
var last_global_middleware_name : str | None
+
+

The type of the None singleton.

+
+
var request : BoltRequest
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/index.html b/docs/reference/index.html new file mode 100644 index 000000000..ac1666851 --- /dev/null +++ b/docs/reference/index.html @@ -0,0 +1,6449 @@ + + + + + + +slack_bolt API documentation + + + + + + + + + + + +
+
+
+

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.

+ +
+
+

Sub-modules

+
+
slack_bolt.adapter
+
+

Adapter modules for running Bolt apps along with Web frameworks or Socket Mode.

+
+
slack_bolt.app
+
+

Application interface in Bolt …

+
+
slack_bolt.async_app
+
+

Module for creating asyncio based apps …

+
+
slack_bolt.authorization
+
+

Authorization is the process of determining which Slack credentials should be available +while processing an incoming Slack event …

+
+
slack_bolt.context
+
+

All listeners have access to a context dictionary, which can be used to enrich events with additional information. +Bolt automatically attaches …

+
+
slack_bolt.error
+
+

Bolt specific error types.

+
+
slack_bolt.kwargs_injection
+
+

For middleware/listener arguments, Bolt does flexible data injection in accordance with their names …

+
+
slack_bolt.lazy_listener
+
+

Lazy listener runner is a beta feature for the apps running on Function-as-a-Service platforms …

+
+
slack_bolt.listener
+
+

Listeners process an incoming request from Slack if the request's type or data structure matches +the predefined conditions of the listener. Typically, …

+
+
slack_bolt.listener_matcher
+
+

A listener matcher is a simplified version of listener middleware. +A listener matcher function returns bool value instead of next() method …

+
+
slack_bolt.logger
+
+

Bolt for Python relies on the standard logging module.

+
+
slack_bolt.middleware
+
+

A middleware processes request data and calls next() method +if the execution chain should continue running the following middleware …

+
+
slack_bolt.oauth
+
+

Slack OAuth flow support for building an app that is installable in any workspaces …

+
+
slack_bolt.request
+
+

Incoming request from Slack through either HTTP request or Socket Mode connection …

+
+
slack_bolt.response
+
+

This interface represents Bolt's synchronous response to Slack …

+
+
slack_bolt.util
+
+

Internal utilities for the Bolt framework.

+
+
slack_bolt.version
+
+

Check the latest version at https://pypi.org/project/slack-bolt/

+
+
slack_bolt.workflows
+
+

Steps from apps enables developers to build their own steps …

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Ack +
+
+
+ +Expand source code + +
class Ack:
+    response: Optional[BoltResponse]
+
+    def __init__(self):
+        self.response: Optional[BoltResponse] = None
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",  # text: str or whole_response: dict
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        response_type: Optional[str] = None,  # in_channel / ephemeral
+        # block_suggestion / dialog_suggestion
+        options: Optional[Sequence[Union[dict, Option]]] = None,
+        option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None,
+        # view_submission
+        response_action: Optional[str] = None,  # errors / update / push / clear
+        errors: Optional[Dict[str, str]] = None,
+        view: Optional[Union[dict, View]] = None,
+    ) -> BoltResponse:
+        return _set_response(
+            self,
+            text_or_whole_response=text,
+            blocks=blocks,
+            attachments=attachments,
+            unfurl_links=unfurl_links,
+            unfurl_media=unfurl_media,
+            response_type=response_type,
+            options=options,
+            option_groups=option_groups,
+            response_action=response_action,
+            errors=errors,
+            view=view,
+        )
+
+
+

Class variables

+
+
var responseBoltResponse | None
+
+

The type of the None singleton.

+
+
+
+
+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,
attaching_conversation_kwargs_enabled: bool = True)
+
+
+
+ +Expand source code + +
class App:
+    def __init__(
+        self,
+        *,
+        logger: Optional[logging.Logger] = None,
+        # Used in logger
+        name: Optional[str] = None,
+        # Set True when you run this app on a FaaS platform
+        process_before_response: bool = False,
+        # Set True if you want to handle an unhandled request as an exception
+        raise_error_for_unhandled_request: bool = False,
+        # Basic Information > Credentials > Signing Secret
+        signing_secret: Optional[str] = None,
+        # for single-workspace apps
+        token: Optional[str] = None,
+        token_verification_enabled: bool = True,
+        client: Optional[WebClient] = None,
+        # for multi-workspace apps
+        before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None,
+        authorize: Optional[Callable[..., AuthorizeResult]] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+        installation_store: Optional[InstallationStore] = None,
+        # for either only bot scope usage or v1.0.x compatibility
+        installation_store_bot_only: Optional[bool] = None,
+        # for customizing the built-in middleware
+        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,
+        # for the OAuth flow
+        oauth_settings: Optional[OAuthSettings] = None,
+        oauth_flow: Optional[OAuthFlow] = None,
+        # No need to set (the value is used only in response to ssl_check requests)
+        verification_token: Optional[str] = None,
+        # Set this one only when you want to customize the executor
+        listener_executor: Optional[Executor] = None,
+        # 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.
+
+            import os
+            from slack_bolt import App
+
+            # Initializes your app with your bot token and signing secret
+            app = App(
+                token=os.environ.get("SLACK_BOT_TOKEN"),
+                signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+            )
+
+            # Listens to incoming messages that contain "hello"
+            @app.message("hello")
+            def message_hello(message, say):
+                # say() sends a message to the channel where the event was triggered
+                say(f"Hey there <@{message['user']}>!")
+
+            # Start your app
+            if __name__ == "__main__":
+                app.start(port=int(os.environ.get("PORT", 3000)))
+
+        Refer to https://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:
+            logger: The custom logger that can be used in this app.
+            name: The application name that will be used in logging. If absent, the source file name will be used.
+            process_before_response: True if this app runs on Function as a Service. (Default: False)
+            raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests
+                and use @app.error listeners instead of
+                the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+            signing_secret: The Signing Secret value used for verifying requests from Slack.
+            token: The bot/user access token required only for single-workspace app.
+            token_verification_enabled: Verifies the validity of the given token if True.
+            client: The singleton `slack_sdk.WebClient` instance for this app.
+            before_authorize: A global middleware that can be executed right before authorize function
+            authorize: The function to authorize an incoming request from Slack
+                by checking if there is a team/user in the installation data.
+            user_facing_authorize_error_message: The user-facing error message to display
+                when the app is installed but the installation is not managed by this app's installation store
+            installation_store: The module offering save/find operations of installation data
+            installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False)
+            request_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests.
+                Make sure if it's safe enough when you turn a built-in middleware off.
+                We strongly recommend using RequestVerification for better security.
+                If you have a proxy that verifies request signature in front of the Bolt app,
+                it's totally fine to disable RequestVerification to avoid duplication of work.
+                Don't turn it off just for easiness of development.
+            ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events
+                generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+            ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware.
+                `IgnoringSelfEvents` for this app's bot user message events within an assistant thread
+                This is useful for avoiding code error causing an infinite loop; Default: True
+            url_verification_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `UrlVerification` is a built-in middleware that handles url_verification requests
+                that verify the endpoint for Events API in HTTP Mode requests.
+            attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True).
+                `AttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution tokens
+                when your app receives `function_executed` or interactivity events scoped to a custom step.
+            ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True).
+                `SslCheck` is a built-in middleware that handles ssl_check requests from Slack.
+            oauth_settings: The settings related to Slack app installation flow (OAuth flow)
+            oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings.
+            verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests.
+            listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will
+                be used.
+            assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation,
+                which uses a parent message's metadata to store the latest context)
+        """
+        if signing_secret is None:
+            signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "")
+        token = token or os.environ.get("SLACK_BOT_TOKEN")
+
+        self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1]
+        self._signing_secret: str = signing_secret
+
+        self._verification_token: Optional[str] = verification_token or os.environ.get("SLACK_VERIFICATION_TOKEN", None)
+        # If a logger is explicitly passed when initializing, the logger works as the base logger.
+        # The base logger's logging settings will be propagated to all the loggers created by bolt-python.
+        self._base_logger = logger
+        # The framework logger is supposed to be used for the internal logging.
+        # Also, it's accessible via `app.logger` as the app's singleton logger.
+        self._framework_logger = logger or get_bolt_logger(App)
+        self._raise_error_for_unhandled_request = raise_error_for_unhandled_request
+
+        self._token: Optional[str] = token
+
+        if client is not None:
+            if not isinstance(client, WebClient):
+                raise BoltError(error_client_invalid_type())
+            self._client = client
+            self._token = client.token
+            if token is not None:
+                self._framework_logger.warning(warning_client_prioritized_and_token_skipped())
+        else:
+            self._client = create_web_client(
+                # NOTE: the token here can be None
+                token=token,
+                logger=self._framework_logger,
+            )
+
+        # --------------------------------------
+        # Authorize & OAuthFlow initialization
+        # --------------------------------------
+
+        self._before_authorize: Optional[Middleware] = None
+        if before_authorize is not None:
+            if callable(before_authorize):
+                self._before_authorize = CustomMiddleware(
+                    app_name=self._name,
+                    func=before_authorize,
+                    base_logger=self._framework_logger,
+                )
+            elif isinstance(before_authorize, Middleware):
+                self._before_authorize = before_authorize
+
+        self._authorize: Optional[Authorize] = None
+        if authorize is not None:
+            if isinstance(authorize, Authorize):
+                # As long as an advanced developer understands what they're doing,
+                # bolt-python should not prevent customizing authorize middleware
+                self._authorize = authorize
+            else:
+                if oauth_settings is not None or oauth_flow is not None:
+                    # If the given authorize is a simple function,
+                    # it does not work along with installation_store.
+                    raise BoltError(error_authorize_conflicts())
+                self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize)
+
+        self._installation_store: Optional[InstallationStore] = installation_store
+        if self._installation_store is not None and self._authorize is None:
+            settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
+            self._authorize = InstallationStoreAuthorize(
+                installation_store=self._installation_store,
+                client_id=settings.client_id if settings is not None else None,
+                client_secret=settings.client_secret if settings is not None else None,
+                logger=self._framework_logger,
+                bot_only=installation_store_bot_only or False,
+                client=self._client,  # for proxy use cases etc.
+                user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"),
+            )
+
+        self._oauth_flow: Optional[OAuthFlow] = None
+
+        if (
+            oauth_settings is None
+            and os.environ.get("SLACK_CLIENT_ID") is not None
+            and os.environ.get("SLACK_CLIENT_SECRET") is not None
+        ):
+            # initialize with the default settings
+            oauth_settings = OAuthSettings()
+
+            if oauth_flow is None and installation_store is None:
+                # show info-level log for avoiding confusions
+                self._framework_logger.info(info_default_oauth_settings_loaded())
+
+        if oauth_flow is not None:
+            self._oauth_flow = oauth_flow
+            installation_store = select_consistent_installation_store(
+                client_id=self._oauth_flow.client_id,
+                app_store=self._installation_store,
+                oauth_flow_store=self._oauth_flow.settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._installation_store = installation_store
+            if installation_store is not None:
+                self._oauth_flow.settings.installation_store = installation_store
+
+            if self._oauth_flow._client is None:
+                self._oauth_flow._client = self._client
+            if self._authorize is None:
+                self._authorize = self._oauth_flow.settings.authorize
+        elif oauth_settings is not None:
+            installation_store = select_consistent_installation_store(
+                client_id=oauth_settings.client_id,
+                app_store=self._installation_store,
+                oauth_flow_store=oauth_settings.installation_store,
+                logger=self._framework_logger,
+            )
+            self._installation_store = installation_store
+            if installation_store is not None:
+                oauth_settings.installation_store = installation_store
+            self._oauth_flow = OAuthFlow(client=self.client, logger=self.logger, settings=oauth_settings)
+            if self._authorize is None:
+                self._authorize = self._oauth_flow.settings.authorize
+            self._authorize.token_rotation_expiration_minutes = oauth_settings.token_rotation_expiration_minutes  # type: ignore[attr-defined] # noqa: E501
+
+        if (self._installation_store is not None or self._authorize is not None) and self._token is not None:
+            self._token = None
+            self._framework_logger.warning(warning_token_skipped())
+
+        # after setting bot_only here, __init__ cannot replace authorize function
+        if installation_store_bot_only is not None and self._oauth_flow is not None:
+            app_bot_only = installation_store_bot_only or False
+            oauth_flow_bot_only = self._oauth_flow.settings.installation_store_bot_only
+            if app_bot_only != oauth_flow_bot_only:
+                self.logger.warning(warning_bot_only_conflicts())
+                self._oauth_flow.settings.installation_store_bot_only = app_bot_only
+                self._authorize.bot_only = app_bot_only  # type: ignore[union-attr]
+
+        self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None
+        if self._installation_store is not None:
+            self._tokens_revocation_listeners = TokenRevocationListeners(self._installation_store)
+
+        # --------------------------------------
+        # Middleware Initialization
+        # --------------------------------------
+
+        self._middleware_list: List[Middleware] = []
+        self._listeners: List[Listener] = []
+
+        if listener_executor is None:
+            listener_executor = ThreadPoolExecutor(max_workers=5)
+
+        self._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(
+            logger=self._framework_logger,
+            process_before_response=process_before_response,
+            listener_error_handler=DefaultListenerErrorHandler(logger=self._framework_logger),
+            listener_start_handler=DefaultListenerStartHandler(logger=self._framework_logger),
+            listener_completion_handler=DefaultListenerCompletionHandler(logger=self._framework_logger),
+            listener_executor=listener_executor,
+            lazy_listener_runner=ThreadLazyListenerRunner(
+                logger=self._framework_logger,
+                executor=listener_executor,
+            ),
+        )
+        self._middleware_error_handler: MiddlewareErrorHandler = DefaultMiddlewareErrorHandler(
+            logger=self._framework_logger,
+        )
+
+        self._init_middleware_list_done = False
+        self._init_middleware_list(
+            token_verification_enabled=token_verification_enabled,
+            request_verification_enabled=request_verification_enabled,
+            ignoring_self_events_enabled=ignoring_self_events_enabled,
+            ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+            ssl_check_enabled=ssl_check_enabled,
+            url_verification_enabled=url_verification_enabled,
+            attaching_function_token_enabled=attaching_function_token_enabled,
+            user_facing_authorize_error_message=user_facing_authorize_error_message,
+        )
+
+    def _init_middleware_list(
+        self,
+        token_verification_enabled: bool = True,
+        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,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        if self._init_middleware_list_done:
+            return
+        if ssl_check_enabled is True:
+            self._middleware_list.append(
+                SslCheck(
+                    verification_token=self._verification_token,
+                    base_logger=self._base_logger,
+                )
+            )
+        if request_verification_enabled is True:
+            self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger))
+
+        if self._before_authorize is not None:
+            self._middleware_list.append(self._before_authorize)
+
+        # As authorize is required for making a Bolt app function, we don't offer the flag to disable this
+        if self._oauth_flow is None:
+            if self._token is not None:
+                try:
+                    auth_test_result = None
+                    if token_verification_enabled:
+                        # This API call is for eagerly validating the token
+                        auth_test_result = self._client.auth_test(token=self._token)
+                    self._middleware_list.append(
+                        SingleTeamAuthorization(
+                            auth_test_result=auth_test_result,
+                            base_logger=self._base_logger,
+                            user_facing_authorize_error_message=user_facing_authorize_error_message,
+                        )
+                    )
+                except SlackApiError as err:
+                    raise BoltError(error_auth_test_failure(err.response))
+            elif self._authorize is not None:
+                self._middleware_list.append(
+                    MultiTeamsAuthorization(
+                        authorize=self._authorize,
+                        base_logger=self._base_logger,
+                        user_facing_authorize_error_message=user_facing_authorize_error_message,
+                    )
+                )
+            else:
+                raise BoltError(error_token_required())
+        elif self._authorize is not None:
+            self._middleware_list.append(
+                MultiTeamsAuthorization(
+                    authorize=self._authorize,
+                    base_logger=self._base_logger,
+                    user_token_resolution=self._oauth_flow.settings.user_token_resolution,
+                    user_facing_authorize_error_message=user_facing_authorize_error_message,
+                )
+            )
+        else:
+            raise BoltError(error_oauth_flow_or_authorize_required())
+
+        if ignoring_self_events_enabled is True:
+            self._middleware_list.append(
+                IgnoringSelfEvents(
+                    base_logger=self._base_logger,
+                    ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled,
+                )
+            )
+        if url_verification_enabled is True:
+            self._middleware_list.append(UrlVerification(base_logger=self._base_logger))
+        if attaching_function_token_enabled is True:
+            self._middleware_list.append(AttachingFunctionToken())
+        self._init_middleware_list_done = True
+
+    # -------------------------
+    # accessors
+
+    @property
+    def name(self) -> str:
+        """The name of this app (default: the filename)"""
+        return self._name
+
+    @property
+    def oauth_flow(self) -> Optional[OAuthFlow]:
+        """Configured `OAuthFlow` object if exists."""
+        return self._oauth_flow
+
+    @property
+    def logger(self) -> logging.Logger:
+        """The logger this app uses."""
+        return self._framework_logger
+
+    @property
+    def client(self) -> WebClient:
+        """The singleton `slack_sdk.WebClient` instance in this app."""
+        return self._client
+
+    @property
+    def installation_store(self) -> Optional[InstallationStore]:
+        """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
+        return self._installation_store
+
+    @property
+    def listener_runner(self) -> ThreadListenerRunner:
+        """The thread executor for asynchronously running listeners."""
+        return self._listener_runner
+
+    @property
+    def process_before_response(self) -> bool:
+        return self._process_before_response or False
+
+    # -------------------------
+    # standalone server
+
+    def start(
+        self,
+        port: int = 3000,
+        path: str = "/slack/events",
+        http_server_logger_enabled: bool = True,
+    ) -> None:
+        """Starts a web server for local development.
+
+            # With the default settings, `http://localhost:3000/slack/events`
+            # is available for handling incoming requests from Slack
+            app.start()
+
+        This method internally starts a Web server process built with the `http.server` module.
+        For production, consider using a production-ready WSGI server such as Gunicorn.
+
+        Args:
+            port: The port to listen on (Default: 3000)
+            path: The path to handle request from Slack (Default: `/slack/events`)
+            http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
+        """
+        self._development_server = SlackAppDevelopmentServer(
+            port=port,
+            path=path,
+            app=self,
+            oauth_flow=self.oauth_flow,
+            http_server_logger_enabled=http_server_logger_enabled,
+        )
+        self._development_server.start()
+
+    # -------------------------
+    # main dispatcher
+
+    def dispatch(self, req: BoltRequest) -> BoltResponse:
+        """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+        Args:
+            req: An incoming request from Slack
+
+        Returns:
+            The response generated by this Bolt app
+        """
+        starting_time = time.time()
+        self._init_context(req)
+
+        resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+        middleware_state = {"next_called": False}
+
+        def middleware_next():
+            middleware_state["next_called"] = True
+
+        try:
+            for middleware in self._middleware_list:
+                middleware_state["next_called"] = False
+                if self._framework_logger.level <= logging.DEBUG:
+                    self._framework_logger.debug(debug_applying_middleware(middleware.name))
+                resp = middleware.process(req=req, resp=resp, next=middleware_next)  # type: ignore[arg-type]
+                if not middleware_state["next_called"]:
+                    if resp is None:
+                        # next() method was not called without providing the response to return to Slack
+                        # This should not be an intentional handling in usual use cases.
+                        resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                        if self._raise_error_for_unhandled_request is True:
+                            try:
+                                raise BoltUnhandledRequestError(
+                                    request=req,
+                                    current_response=resp,
+                                    last_global_middleware_name=middleware.name,
+                                )
+                            except BoltUnhandledRequestError as e:
+                                self._listener_runner.listener_error_handler.handle(
+                                    error=e,
+                                    request=req,
+                                    response=resp,
+                                )
+                            return resp
+                        self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                        return resp
+                    return resp
+
+            for listener in self._listeners:
+                listener_name = get_name_for_callable(listener.ack_function)
+                self._framework_logger.debug(debug_checking_listener(listener_name))
+                if listener.matches(req=req, resp=resp):  # type: ignore[arg-type]
+                    # run all the middleware attached to this listener first
+                    middleware_resp, next_was_not_called = listener.run_middleware(
+                        req=req, resp=resp  # type: ignore[arg-type]
+                    )
+                    if next_was_not_called:
+                        if middleware_resp is not None:
+                            if self._framework_logger.level <= logging.DEBUG:
+                                debug_message = debug_return_listener_middleware_response(
+                                    listener_name,
+                                    middleware_resp.status,
+                                    middleware_resp.body,
+                                    starting_time,
+                                )
+                                self._framework_logger.debug(debug_message)
+                            return middleware_resp
+                        # The last listener middleware didn't call next() method.
+                        # This means the listener is not for this incoming request.
+                        continue
+
+                    if middleware_resp is not None:
+                        resp = middleware_resp
+
+                    self._framework_logger.debug(debug_running_listener(listener_name))
+                    listener_response: Optional[BoltResponse] = self._listener_runner.run(
+                        request=req,
+                        response=resp,  # type: ignore[arg-type]
+                        listener_name=listener_name,
+                        listener=listener,
+                    )
+                    if listener_response is not None:
+                        return listener_response
+
+            if resp is None:
+                resp = BoltResponse(status=404, body={"error": "unhandled request"})
+            if self._raise_error_for_unhandled_request is True:
+                try:
+                    raise BoltUnhandledRequestError(
+                        request=req,
+                        current_response=resp,
+                    )
+                except BoltUnhandledRequestError as e:
+                    self._listener_runner.listener_error_handler.handle(
+                        error=e,
+                        request=req,
+                        response=resp,
+                    )
+                return resp
+            return self._handle_unmatched_requests(req, resp)
+        except Exception as error:
+            resp = BoltResponse(status=500, body="")
+            self._middleware_error_handler.handle(
+                error=error,
+                request=req,
+                response=resp,
+            )
+            return resp
+
+    def _handle_unmatched_requests(self, req: BoltRequest, resp: BoltResponse) -> BoltResponse:
+        self._framework_logger.warning(warning_unhandled_request(req))
+        return resp
+
+    # -------------------------
+    # middleware
+
+    def use(self, *args) -> Optional[Callable]:
+        """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
+
+        Refer to `App#middleware()` method's docstring for details."""
+        return self.middleware(*args)
+
+    def middleware(self, *args) -> Optional[Callable]:
+        """Registers a new middleware to this app.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.middleware
+            def middleware_func(logger, body, next):
+                logger.info(f"request body: {body}")
+                next()
+
+            # Pass a function to this method
+            app.middleware(middleware_func)
+
+        Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            *args: A function that works as a global middleware.
+        """
+        if len(args) > 0:
+            middleware_or_callable = args[0]
+            if isinstance(middleware_or_callable, Middleware):
+                middleware: Middleware = middleware_or_callable
+                self._middleware_list.append(middleware)
+                if isinstance(middleware, Assistant) and middleware.thread_context_store is not None:
+                    self._assistant_thread_context_store = middleware.thread_context_store
+            elif callable(middleware_or_callable):
+                self._middleware_list.append(
+                    CustomMiddleware(
+                        app_name=self.name,
+                        func=middleware_or_callable,
+                        base_logger=self._base_logger,
+                    )
+                )
+                return middleware_or_callable
+            else:
+                raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+        return None
+
+    # -------------------------
+    # AI Agents & Assistants
+
+    def assistant(self, assistant: Assistant) -> Optional[Callable]:
+        return self.middleware(assistant)
+
+    # -------------------------
+    # Workflows: Steps from apps
+
+    def step(
+        self,
+        callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
+        edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+        save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+        execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new step from app listener.
+
+        Unlike others, this method doesn't behave as a decorator.
+        If you want to register a step from app by a decorator, use `WorkflowStepBuilder`'s methods.
+
+            # Create a new WorkflowStep instance
+            from slack_bolt.workflows.step import WorkflowStep
+            ws = WorkflowStep(
+                callback_id="add_task",
+                edit=edit,
+                save=save,
+                execute=execute,
+            )
+            # Pass Step to set up listeners
+            app.step(ws)
+
+        Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        For further information about WorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            callback_id: The Callback ID for this step from app
+            edit: The function for displaying a modal in the Workflow Builder
+            save: The function for handling configuration in the Workflow Builder
+            execute: The function for handling the step execution
+        """
+        warnings.warn(
+            (
+                "Steps from apps for legacy workflows are now deprecated. "
+                "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+            ),
+            category=DeprecationWarning,
+        )
+        step = callback_id
+        if isinstance(callback_id, (str, Pattern)):
+            step = WorkflowStep(
+                callback_id=callback_id,
+                edit=edit,  # type: ignore[arg-type]
+                save=save,  # type: ignore[arg-type]
+                execute=execute,  # type: ignore[arg-type]
+                base_logger=self._base_logger,
+            )
+        elif isinstance(step, WorkflowStepBuilder):
+            step = step.build(base_logger=self._base_logger)
+        elif not isinstance(step, WorkflowStep):
+            raise BoltError(f"Invalid step object ({type(step)})")
+
+        self.use(WorkflowStepMiddleware(step))
+
+    # -------------------------
+    # global error handler
+
+    def error(self, func: Callable[..., Optional[BoltResponse]]) -> Callable[..., Optional[BoltResponse]]:
+        """Updates the global error handler. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.error
+            def custom_error_handler(error, body, logger):
+                logger.exception(f"Error: {error}")
+                logger.info(f"Request body: {body}")
+
+            # Pass a function to this method
+            app.error(custom_error_handler)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            func: The function that is supposed to be executed
+                when getting an unhandled error in Bolt app.
+        """
+        self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        self._middleware_error_handler = CustomMiddlewareErrorHandler(
+            logger=self._framework_logger,
+            func=func,
+        )
+        return func
+
+    # -------------------------
+    # events
+
+    def event(
+        self,
+        event: Union[
+            str,
+            Pattern,
+            Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+        ],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new event listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.event("team_join")
+            def ask_for_introduction(event, say):
+                welcome_channel_id = "C12345"
+                user_id = event["user"]
+                text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+                say(text=text, channel=welcome_channel_id)
+
+            # Pass a function to this method
+            app.event("team_join")(ask_for_introduction)
+
+        Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            event: The conditions that match a request payload.
+                If you pass a dict for this, you can have type, subtype in the constraint.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        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__
+
+    def message(
+        self,
+        keyword: Union[str, Pattern] = "",
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new message event listener. This method can be used as either a decorator or a method.
+        Check the `App#event` method's docstring for details.
+
+            # Use this method as a decorator
+            @app.message(":wave:")
+            def say_hello(message, say):
+                user = message['user']
+                say(f"Hi there, <@{user}>!")
+
+            # Pass a function to this method
+            app.message(":wave:")(say_hello)
+
+        Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            keyword: The keyword to match
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            constraints = {
+                "type": "message",
+                "subtype": (
+                    # In most cases, new message events come with no subtype.
+                    None,
+                    # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                    # By contrast, messages posted using classic app's bot token still have the subtype.
+                    "bot_message",
+                    # If an end-user posts a message with "Also send to #channel" checked,
+                    # the message event comes with this subtype.
+                    "thread_broadcast",
+                    # If an end-user posts a message with attached files,
+                    # the message event comes with this subtype.
+                    "file_share",
+                ),
+            }
+            primary_matcher = builtin_matchers.message_event(
+                keyword=keyword, constraints=constraints, base_logger=self._base_logger
+            )
+            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)
+
+        return __call__
+
+    def function(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+        auto_acknowledge: bool = True,
+        ack_timeout: int = 3,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new Function listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.function("reverse")
+            def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+                try:
+                    ack()
+                    string_to_reverse = inputs["stringToReverse"]
+                    complete(outputs={"reverseString": string_to_reverse[::-1]})
+                except Exception as e:
+                    fail(f"Cannot reverse string (error: {e})")
+                    raise e
+
+            # Pass a function to this method
+            app.function("reverse")(reverse_string)
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            callback_id: The callback id to identify the function
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        if auto_acknowledge is True:
+            if ack_timeout != 3:
+                self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+        matchers = list(matchers) if matchers else []
+        middleware = list(middleware) if middleware else []
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger)
+            return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+        return __call__
+
+    # -------------------------
+    # slash commands
+
+    def command(
+        self,
+        command: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new slash command listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.command("/echo")
+            def repeat_text(ack, say, command):
+                # Acknowledge command request
+                ack()
+                say(f"{command['text']}")
+
+            # Pass a function to this method
+            app.command("/echo")(repeat_text)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            command: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # shortcut
+
+    def shortcut(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new shortcut listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.shortcut("open_modal")
+            def open_modal(ack, body, client):
+                # Acknowledge the command request
+                ack()
+                # Call views_open with the built-in client
+                client.views_open(
+                    # Pass a valid trigger_id within 3 seconds of receiving it
+                    trigger_id=body["trigger_id"],
+                    # View payload
+                    view={ ... }
+                )
+
+            # Pass a function to this method
+            app.shortcut("open_modal")(open_modal)
+
+        Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload.
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.shortcut(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def global_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new global shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.global_shortcut(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def message_shortcut(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new message shortcut listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.message_shortcut(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # action
+
+    def action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new action listener. This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.action("approve_button")
+            def update_message(ack):
+                ack()
+
+            # Pass a function to this method
+            app.action("approve_button")(update_message)
+
+        * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+        * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+        * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.action(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_action(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `block_actions` action listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_action(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def attachment_action(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `interactive_message` action listener.
+        Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.attachment_action(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_submission(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_submission` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_submission(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_cancellation(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_cancellation` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_cancellation(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # view
+
+    def view(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_submission`/`view_closed` event listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.view("view_1")
+            def handle_submission(ack, body, client, view):
+                # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+                hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+                user = body["user"]["id"]
+                # Validate the inputs
+                errors = {}
+                if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                    errors["block_c"] = "The value must be longer than 5 characters"
+                if len(errors) > 0:
+                    ack(response_action="errors", errors=errors)
+                    return
+                # Acknowledge the view_submission event and close the modal
+                ack()
+                # Do whatever you want with the input data - here we're saving it to a DB
+
+            # Pass a function to this method
+            app.view("view_1")(handle_submission)
+
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            constraints: The conditions that match a request payload
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_submission(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_submission` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+        details.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_submission(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def view_closed(
+        self,
+        constraints: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `view_closed` listener.
+        Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # options
+
+    def options(
+        self,
+        constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new options listener.
+        This method can be used as either a decorator or a method.
+
+            # Use this method as a decorator
+            @app.options("menu_selection")
+            def show_menu_options(ack):
+                options = [
+                    {
+                        "text": {"type": "plain_text", "text": "Option 1"},
+                        "value": "1-1",
+                    },
+                    {
+                        "text": {"type": "plain_text", "text": "Option 2"},
+                        "value": "1-2",
+                    },
+                ]
+                ack(options=options)
+
+            # Pass a function to this method
+            app.options("menu_selection")(show_menu_options)
+
+        Refer to the following documents for details:
+
+        * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+        * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+        To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+        Args:
+            matchers: A list of listener matcher functions.
+                Only when all the matchers return True, the listener function can be invoked.
+            middleware: A list of lister middleware functions.
+                Only when all the middleware call `next()` method, the listener function can be invoked.
+        """
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.options(constraints, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def block_suggestion(
+        self,
+        action_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `block_suggestion` listener."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.block_suggestion(action_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    def dialog_suggestion(
+        self,
+        callback_id: Union[str, Pattern],
+        matchers: Optional[Sequence[Callable[..., bool]]] = None,
+        middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+        """Registers a new `dialog_suggestion` listener.
+        Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+        def __call__(*args, **kwargs):
+            functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+            primary_matcher = builtin_matchers.dialog_suggestion(callback_id, base_logger=self._base_logger)
+            return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+        return __call__
+
+    # -------------------------
+    # built-in listener functions
+
+    def default_tokens_revoked_event_listener(
+        self,
+    ) -> Callable[..., Optional[BoltResponse]]:
+        if self._tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._tokens_revocation_listeners.handle_tokens_revoked_events
+
+    def default_app_uninstalled_event_listener(
+        self,
+    ) -> Callable[..., Optional[BoltResponse]]:
+        if self._tokens_revocation_listeners is None:
+            raise BoltError(error_installation_store_required_for_builtin_listeners())
+        return self._tokens_revocation_listeners.handle_app_uninstalled_events
+
+    def enable_token_revocation_listeners(self) -> None:
+        self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+        self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+    # -------------------------
+
+    def _init_context(self, req: BoltRequest):
+        req.context["logger"] = get_bolt_app_logger(app_name=self.name, base_logger=self._base_logger)
+        req.context["token"] = self._token
+        # Prior to version 1.15, when the token is static, self._client was passed to `req.context`.
+        # The intention was to avoid creating a new instance per request
+        # in the interest of runtime performance/memory footprint optimization.
+        # However, developers may want to replace the token held by req.context.client in some situations.
+        # In this case, this behavior can result in thread-unsafe data modification on `self._client`.
+        # (`self._client` a.k.a. `app.client` is a singleton object per an App instance)
+        # Thus, we've changed the behavior to create a new instance per request regardless of token argument
+        # in the App initialization starting v1.15.
+        # The overhead brought by this change is slight so that we believe that it is ignorable in any cases.
+        client_per_request: WebClient = WebClient(
+            token=self._token,  # this can be None, and it can be set later on
+            base_url=self._client.base_url,
+            timeout=self._client.timeout,
+            ssl=self._client.ssl,
+            proxy=self._client.proxy,
+            headers=self._client.headers,
+            team_id=req.context.team_id,
+            logger=self._client.logger,
+            retry_handlers=self._client.retry_handlers.copy() if self._client.retry_handlers is not None else None,
+        )
+        req.context["client"] = client_per_request
+
+        # Most apps do not need this "listener_runner" instance.
+        # It is intended for apps that start lazy listeners from their custom global middleware.
+        req.context["listener_runner"] = self.listener_runner
+
+    @staticmethod
+    def _to_listener_functions(
+        kwargs: dict,
+    ) -> Optional[Sequence[Callable[..., Optional[BoltResponse]]]]:
+        if kwargs:
+            functions = [kwargs["ack"]]
+            for sub in kwargs["lazy"]:
+                functions.append(sub)
+            return functions
+        return None
+
+    def _register_listener(
+        self,
+        functions: Sequence[Callable[..., Optional[BoltResponse]]],
+        primary_matcher: ListenerMatcher,
+        matchers: Optional[Sequence[Callable[..., bool]]],
+        middleware: Optional[Sequence[Union[Callable, Middleware]]],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+    ) -> Optional[Callable[..., Optional[BoltResponse]]]:
+        value_to_return = None
+        if not isinstance(functions, list):
+            functions = list(functions)
+        if len(functions) == 1:
+            # In the case where the function is registered using decorator,
+            # the registration should return the original function.
+            value_to_return = functions[0]
+
+        listener_matchers: List[ListenerMatcher] = [
+            CustomListenerMatcher(app_name=self.name, func=f, base_logger=self._base_logger) for f in (matchers or [])
+        ]
+        listener_matchers.insert(0, primary_matcher)
+        listener_middleware = []
+        for m in middleware or []:
+            if isinstance(m, Middleware):
+                listener_middleware.append(m)
+            elif callable(m):
+                listener_middleware.append(CustomMiddleware(app_name=self.name, func=m, base_logger=self._base_logger))
+            else:
+                raise ValueError(error_unexpected_listener_middleware(type(m)))
+
+        self._listeners.append(
+            CustomListener(
+                app_name=self.name,
+                ack_function=functions.pop(0),
+                lazy_functions=functions,  # type: ignore[arg-type]
+                matchers=listener_matchers,
+                middleware=listener_middleware,
+                auto_acknowledgement=auto_acknowledgement,
+                ack_timeout=ack_timeout,
+                base_logger=self._base_logger,
+            )
+        )
+        return value_to_return
+
+

Bolt App that provides functionalities to register middleware/listeners.

+
import os
+from slack_bolt import App
+
+# Initializes your app with your bot token and signing secret
+app = App(
+    token=os.environ.get("SLACK_BOT_TOKEN"),
+    signing_secret=os.environ.get("SLACK_SIGNING_SECRET")
+)
+
+# Listens to incoming messages that contain "hello"
+@app.message("hello")
+def message_hello(message, say):
+    # say() sends a message to the channel where the event was triggered
+    say(f"Hey there <@{message['user']}>!")
+
+# Start your app
+if __name__ == "__main__":
+    app.start(port=int(os.environ.get("PORT", 3000)))
+
+

Refer to https://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

+
+
logger
+
The custom logger that can be used in this app.
+
name
+
The application name that will be used in logging. If absent, the source file name will be used.
+
process_before_response
+
True if this app runs on Function as a Service. (Default: False)
+
raise_error_for_unhandled_request
+
True if you want to raise exceptions for unhandled requests +and use @app.error listeners instead of +the built-in handler, which pints warning logs and returns 404 to Slack (Default: False)
+
signing_secret
+
The Signing Secret value used for verifying requests from Slack.
+
token
+
The bot/user access token required only for single-workspace app.
+
token_verification_enabled
+
Verifies the validity of the given token if True.
+
client
+
The singleton slack_sdk.WebClient instance for this app.
+
before_authorize
+
A global middleware that can be executed right before authorize function
+
authorize
+
The function to authorize an incoming request from Slack +by checking if there is a team/user in the installation data.
+
user_facing_authorize_error_message
+
The user-facing error message to display +when the app is installed but the installation is not managed by this app's installation store
+
installation_store
+
The module offering save/find operations of installation data
+
installation_store_bot_only
+
Use InstallationStore#find_bot() if True (Default: False)
+
request_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +RequestVerification is a built-in middleware that verifies the signature in HTTP Mode requests. +Make sure if it's safe enough when you turn a built-in middleware off. +We strongly recommend using RequestVerification for better security. +If you have a proxy that verifies request signature in front of the Bolt app, +it's totally fine to disable RequestVerification to avoid duplication of work. +Don't turn it off just for easiness of development.
+
ignoring_self_events_enabled
+
False if you would like to disable the built-in middleware (Default: True). +IgnoringSelfEvents is a built-in middleware that enables Bolt apps to easily skip the events +generated by this app's bot user (this is useful for avoiding code error causing an infinite loop).
+
ignoring_self_assistant_message_events_enabled
+
False if you would like to disable the built-in middleware. +IgnoringSelfEvents for this app's bot user message events within an assistant thread +This is useful for avoiding code error causing an infinite loop; Default: True
+
url_verification_enabled
+
False if you would like to disable the built-in middleware (Default: True). +UrlVerification is a built-in middleware that handles url_verification requests +that verify the endpoint for Events API in HTTP Mode requests.
+
attaching_function_token_enabled
+
False if you would like to disable the built-in middleware (Default: True). +AttachingFunctionToken is a built-in middleware that injects the just-in-time workflow-execution tokens +when your app receives function_executed or interactivity events scoped to a custom step.
+
ssl_check_enabled
+
bool = False if you would like to disable the built-in middleware (Default: True). +SslCheck is a built-in middleware that handles ssl_check requests from Slack.
+
oauth_settings
+
The settings related to Slack app installation flow (OAuth flow)
+
oauth_flow
+
Instantiated OAuthFlow. This is always prioritized over oauth_settings.
+
verification_token
+
Deprecated verification mechanism. This can be used only for ssl_check requests.
+
listener_executor
+
Custom executor to run background tasks. If absent, the default ThreadPoolExecutor will +be used.
+
assistant_thread_context_store
+
Custom AssistantThreadContext store (Default: the built-in implementation, +which uses a parent message's metadata to store the latest context)
+
+

Instance variables

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    """The singleton `slack_sdk.WebClient` instance in this app."""
+    return self._client
+
+

The singleton slack_sdk.WebClient instance in this app.

+
+
prop installation_store : slack_sdk.oauth.installation_store.installation_store.InstallationStore | None
+
+
+ +Expand source code + +
@property
+def installation_store(self) -> Optional[InstallationStore]:
+    """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware."""
+    return self._installation_store
+
+

The slack_sdk.oauth.InstallationStore that can be used in the authorize middleware.

+
+
prop listener_runnerThreadListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> ThreadListenerRunner:
+    """The thread executor for asynchronously running listeners."""
+    return self._listener_runner
+
+

The thread executor for asynchronously running listeners.

+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> logging.Logger:
+    """The logger this app uses."""
+    return self._framework_logger
+
+

The logger this app uses.

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this app (default: the filename)"""
+    return self._name
+
+

The name of this app (default: the filename)

+
+
prop oauth_flowOAuthFlow | None
+
+
+ +Expand source code + +
@property
+def oauth_flow(self) -> Optional[OAuthFlow]:
+    """Configured `OAuthFlow` object if exists."""
+    return self._oauth_flow
+
+

Configured OAuthFlow object if exists.

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

Methods

+
+
+def action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new action listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.action("approve_button")
+        def update_message(ack):
+            ack()
+
+        # Pass a function to this method
+        app.action("approve_button")(update_message)
+
+    * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`.
+    * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`.
+    * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.action(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.action("approve_button")
+def update_message(ack):
+    ack()
+
+# Pass a function to this method
+app.action("approve_button")(update_message)
+
+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def assistant(self,
assistant: Assistant) ‑> Callable | None
+
+
+
+ +Expand source code + +
def assistant(self, assistant: Assistant) -> Optional[Callable]:
+    return self.middleware(assistant)
+
+
+
+
+def attachment_action(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def attachment_action(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `interactive_message` action listener.
+    Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.attachment_action(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new interactive_message action listener. +Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.

+
+
+def block_action(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def block_action(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `block_actions` action listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_action(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_actions action listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details.

+
+
+def block_suggestion(self,
action_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def block_suggestion(
+    self,
+    action_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `block_suggestion` listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.block_suggestion(action_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new block_suggestion listener.

+
+
+def command(self,
command: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def command(
+    self,
+    command: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new slash command listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.command("/echo")
+        def repeat_text(ack, say, command):
+            # Acknowledge command request
+            ack()
+            say(f"{command['text']}")
+
+        # Pass a function to this method
+        app.command("/echo")(repeat_text)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        command: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new slash command listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.command("/echo")
+def repeat_text(ack, say, command):
+    # Acknowledge command request
+    ack()
+    say(f"{command['text']}")
+
+# Pass a function to this method
+app.command("/echo")(repeat_text)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
command
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def default_app_uninstalled_event_listener(self) ‑> Callable[..., BoltResponse | None] +
+
+
+ +Expand source code + +
def default_app_uninstalled_event_listener(
+    self,
+) -> Callable[..., Optional[BoltResponse]]:
+    if self._tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._tokens_revocation_listeners.handle_app_uninstalled_events
+
+
+
+
+def default_tokens_revoked_event_listener(self) ‑> Callable[..., BoltResponse | None] +
+
+
+ +Expand source code + +
def default_tokens_revoked_event_listener(
+    self,
+) -> Callable[..., Optional[BoltResponse]]:
+    if self._tokens_revocation_listeners is None:
+        raise BoltError(error_installation_store_required_for_builtin_listeners())
+    return self._tokens_revocation_listeners.handle_tokens_revoked_events
+
+
+
+
+def dialog_cancellation(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_cancellation(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_cancellation` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_cancellation(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_cancellation listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_submission(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_submission(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_submission` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_submission(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_submission listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dialog_suggestion(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def dialog_suggestion(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `dialog_suggestion` listener.
+    Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.dialog_suggestion(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new dialog_suggestion listener. +Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.

+
+
+def dispatch(self,
req: BoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def dispatch(self, req: BoltRequest) -> BoltResponse:
+    """Applies all middleware and dispatches an incoming request from Slack to the right code path.
+
+    Args:
+        req: An incoming request from Slack
+
+    Returns:
+        The response generated by this Bolt app
+    """
+    starting_time = time.time()
+    self._init_context(req)
+
+    resp: Optional[BoltResponse] = BoltResponse(status=200, body="")
+    middleware_state = {"next_called": False}
+
+    def middleware_next():
+        middleware_state["next_called"] = True
+
+    try:
+        for middleware in self._middleware_list:
+            middleware_state["next_called"] = False
+            if self._framework_logger.level <= logging.DEBUG:
+                self._framework_logger.debug(debug_applying_middleware(middleware.name))
+            resp = middleware.process(req=req, resp=resp, next=middleware_next)  # type: ignore[arg-type]
+            if not middleware_state["next_called"]:
+                if resp is None:
+                    # next() method was not called without providing the response to return to Slack
+                    # This should not be an intentional handling in usual use cases.
+                    resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"})
+                    if self._raise_error_for_unhandled_request is True:
+                        try:
+                            raise BoltUnhandledRequestError(
+                                request=req,
+                                current_response=resp,
+                                last_global_middleware_name=middleware.name,
+                            )
+                        except BoltUnhandledRequestError as e:
+                            self._listener_runner.listener_error_handler.handle(
+                                error=e,
+                                request=req,
+                                response=resp,
+                            )
+                        return resp
+                    self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req))
+                    return resp
+                return resp
+
+        for listener in self._listeners:
+            listener_name = get_name_for_callable(listener.ack_function)
+            self._framework_logger.debug(debug_checking_listener(listener_name))
+            if listener.matches(req=req, resp=resp):  # type: ignore[arg-type]
+                # run all the middleware attached to this listener first
+                middleware_resp, next_was_not_called = listener.run_middleware(
+                    req=req, resp=resp  # type: ignore[arg-type]
+                )
+                if next_was_not_called:
+                    if middleware_resp is not None:
+                        if self._framework_logger.level <= logging.DEBUG:
+                            debug_message = debug_return_listener_middleware_response(
+                                listener_name,
+                                middleware_resp.status,
+                                middleware_resp.body,
+                                starting_time,
+                            )
+                            self._framework_logger.debug(debug_message)
+                        return middleware_resp
+                    # The last listener middleware didn't call next() method.
+                    # This means the listener is not for this incoming request.
+                    continue
+
+                if middleware_resp is not None:
+                    resp = middleware_resp
+
+                self._framework_logger.debug(debug_running_listener(listener_name))
+                listener_response: Optional[BoltResponse] = self._listener_runner.run(
+                    request=req,
+                    response=resp,  # type: ignore[arg-type]
+                    listener_name=listener_name,
+                    listener=listener,
+                )
+                if listener_response is not None:
+                    return listener_response
+
+        if resp is None:
+            resp = BoltResponse(status=404, body={"error": "unhandled request"})
+        if self._raise_error_for_unhandled_request is True:
+            try:
+                raise BoltUnhandledRequestError(
+                    request=req,
+                    current_response=resp,
+                )
+            except BoltUnhandledRequestError as e:
+                self._listener_runner.listener_error_handler.handle(
+                    error=e,
+                    request=req,
+                    response=resp,
+                )
+            return resp
+        return self._handle_unmatched_requests(req, resp)
+    except Exception as error:
+        resp = BoltResponse(status=500, body="")
+        self._middleware_error_handler.handle(
+            error=error,
+            request=req,
+            response=resp,
+        )
+        return resp
+
+

Applies all middleware and dispatches an incoming request from Slack to the right code path.

+

Args

+
+
req
+
An incoming request from Slack
+
+

Returns

+

The response generated by this Bolt app

+
+
+def enable_token_revocation_listeners(self) ‑> None +
+
+
+ +Expand source code + +
def enable_token_revocation_listeners(self) -> None:
+    self.event("tokens_revoked")(self.default_tokens_revoked_event_listener())
+    self.event("app_uninstalled")(self.default_app_uninstalled_event_listener())
+
+
+
+
+def error(self,
func: Callable[..., BoltResponse | None]) ‑> Callable[..., BoltResponse | None]
+
+
+
+ +Expand source code + +
def error(self, func: Callable[..., Optional[BoltResponse]]) -> Callable[..., Optional[BoltResponse]]:
+    """Updates the global error handler. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.error
+        def custom_error_handler(error, body, logger):
+            logger.exception(f"Error: {error}")
+            logger.info(f"Request body: {body}")
+
+        # Pass a function to this method
+        app.error(custom_error_handler)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        func: The function that is supposed to be executed
+            when getting an unhandled error in Bolt app.
+    """
+    self._listener_runner.listener_error_handler = CustomListenerErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    self._middleware_error_handler = CustomMiddlewareErrorHandler(
+        logger=self._framework_logger,
+        func=func,
+    )
+    return func
+
+

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

+
# Use this method as a decorator
+@app.error
+def custom_error_handler(error, body, logger):
+    logger.exception(f"Error: {error}")
+    logger.info(f"Request body: {body}")
+
+# Pass a function to this method
+app.error(custom_error_handler)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
func
+
The function that is supposed to be executed +when getting an unhandled error in Bolt app.
+
+
+
+def event(self,
event: str | Pattern | Dict[str, str | Sequence[str | Pattern | None] | None],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def event(
+    self,
+    event: Union[
+        str,
+        Pattern,
+        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+    ],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new event listener. This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.event("team_join")
+        def ask_for_introduction(event, say):
+            welcome_channel_id = "C12345"
+            user_id = event["user"]
+            text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+            say(text=text, channel=welcome_channel_id)
+
+        # Pass a function to this method
+        app.event("team_join")(ask_for_introduction)
+
+    Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        event: The conditions that match a request payload.
+            If you pass a dict for this, you can have type, subtype in the constraint.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    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__
+
+

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

+
# Use this method as a decorator
+@app.event("team_join")
+def ask_for_introduction(event, say):
+    welcome_channel_id = "C12345"
+    user_id = event["user"]
+    text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel."
+    say(text=text, channel=welcome_channel_id)
+
+# Pass a function to this method
+app.event("team_join")(ask_for_introduction)
+
+

Refer to https://docs.slack.dev/apis/events-api/ for details of Events API.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
event
+
The conditions that match a request payload. +If you pass a dict for this, you can have type, subtype in the constraint.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def function(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None,
auto_acknowledge: bool = True,
ack_timeout: int = 3) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def function(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+    auto_acknowledge: bool = True,
+    ack_timeout: int = 3,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new Function listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.function("reverse")
+        def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+            try:
+                ack()
+                string_to_reverse = inputs["stringToReverse"]
+                complete(outputs={"reverseString": string_to_reverse[::-1]})
+            except Exception as e:
+                fail(f"Cannot reverse string (error: {e})")
+                raise e
+
+        # Pass a function to this method
+        app.function("reverse")(reverse_string)
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        callback_id: The callback id to identify the function
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    if auto_acknowledge is True:
+        if ack_timeout != 3:
+            self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout))
+
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger)
+        return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.function("reverse")
+def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail):
+    try:
+        ack()
+        string_to_reverse = inputs["stringToReverse"]
+        complete(outputs={"reverseString": string_to_reverse[::-1]})
+    except Exception as e:
+        fail(f"Cannot reverse string (error: {e})")
+        raise e
+
+# Pass a function to this method
+app.function("reverse")(reverse_string)
+
+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
callback_id
+
The callback id to identify the function
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def global_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def global_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new global shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.global_shortcut(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new global shortcut listener.

+
+
+def message(self,
keyword: str | Pattern = '',
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def message(
+    self,
+    keyword: Union[str, Pattern] = "",
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new message event listener. This method can be used as either a decorator or a method.
+    Check the `App#event` method's docstring for details.
+
+        # Use this method as a decorator
+        @app.message(":wave:")
+        def say_hello(message, say):
+            user = message['user']
+            say(f"Hi there, <@{user}>!")
+
+        # Pass a function to this method
+        app.message(":wave:")(say_hello)
+
+    Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        keyword: The keyword to match
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+    matchers = list(matchers) if matchers else []
+    middleware = list(middleware) if middleware else []
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        constraints = {
+            "type": "message",
+            "subtype": (
+                # In most cases, new message events come with no subtype.
+                None,
+                # As of Jan 2021, most bot messages no longer have the subtype bot_message.
+                # By contrast, messages posted using classic app's bot token still have the subtype.
+                "bot_message",
+                # If an end-user posts a message with "Also send to #channel" checked,
+                # the message event comes with this subtype.
+                "thread_broadcast",
+                # If an end-user posts a message with attached files,
+                # the message event comes with this subtype.
+                "file_share",
+            ),
+        }
+        primary_matcher = builtin_matchers.message_event(
+            keyword=keyword, constraints=constraints, base_logger=self._base_logger
+        )
+        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)
+
+    return __call__
+
+

Registers a new message event listener. This method can be used as either a decorator or a method. +Check the App#event method's docstring for details.

+
# Use this method as a decorator
+@app.message(":wave:")
+def say_hello(message, say):
+    user = message['user']
+    say(f"Hi there, <@{user}>!")
+
+# Pass a function to this method
+app.message(":wave:")(say_hello)
+
+

Refer to https://docs.slack.dev/reference/events/message/ for details of message events.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
keyword
+
The keyword to match
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def message_shortcut(self,
callback_id: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def message_shortcut(
+    self,
+    callback_id: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new message shortcut listener."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.message_shortcut(callback_id, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new message shortcut listener.

+
+
+def middleware(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def middleware(self, *args) -> Optional[Callable]:
+    """Registers a new middleware to this app.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.middleware
+        def middleware_func(logger, body, next):
+            logger.info(f"request body: {body}")
+            next()
+
+        # Pass a function to this method
+        app.middleware(middleware_func)
+
+    Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        *args: A function that works as a global middleware.
+    """
+    if len(args) > 0:
+        middleware_or_callable = args[0]
+        if isinstance(middleware_or_callable, Middleware):
+            middleware: Middleware = middleware_or_callable
+            self._middleware_list.append(middleware)
+            if isinstance(middleware, Assistant) and middleware.thread_context_store is not None:
+                self._assistant_thread_context_store = middleware.thread_context_store
+        elif callable(middleware_or_callable):
+            self._middleware_list.append(
+                CustomMiddleware(
+                    app_name=self.name,
+                    func=middleware_or_callable,
+                    base_logger=self._base_logger,
+                )
+            )
+            return middleware_or_callable
+        else:
+            raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})")
+    return None
+
+

Registers a new middleware to this app. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.middleware
+def middleware_func(logger, body, next):
+    logger.info(f"request body: {body}")
+    next()
+
+# Pass a function to this method
+app.middleware(middleware_func)
+
+

Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
*args
+
A function that works as a global middleware.
+
+
+
+def options(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def options(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new options listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.options("menu_selection")
+        def show_menu_options(ack):
+            options = [
+                {
+                    "text": {"type": "plain_text", "text": "Option 1"},
+                    "value": "1-1",
+                },
+                {
+                    "text": {"type": "plain_text", "text": "Option 2"},
+                    "value": "1-2",
+                },
+            ]
+            ack(options=options)
+
+        # Pass a function to this method
+        app.options("menu_selection")(show_menu_options)
+
+    Refer to the following documents for details:
+
+    * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select
+    * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.options(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.options("menu_selection")
+def show_menu_options(ack):
+    options = [
+        {
+            "text": {"type": "plain_text", "text": "Option 1"},
+            "value": "1-1",
+        },
+        {
+            "text": {"type": "plain_text", "text": "Option 2"},
+            "value": "1-2",
+        },
+    ]
+    ack(options=options)
+
+# Pass a function to this method
+app.options("menu_selection")(show_menu_options)
+
+

Refer to the following documents for details:

+ +

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def shortcut(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def shortcut(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new shortcut listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.shortcut("open_modal")
+        def open_modal(ack, body, client):
+            # Acknowledge the command request
+            ack()
+            # Call views_open with the built-in client
+            client.views_open(
+                # Pass a valid trigger_id within 3 seconds of receiving it
+                trigger_id=body["trigger_id"],
+                # View payload
+                view={ ... }
+            )
+
+        # Pass a function to this method
+        app.shortcut("open_modal")(open_modal)
+
+    Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload.
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.shortcut(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

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

+
# Use this method as a decorator
+@app.shortcut("open_modal")
+def open_modal(ack, body, client):
+    # Acknowledge the command request
+    ack()
+    # Call views_open with the built-in client
+    client.views_open(
+        # Pass a valid trigger_id within 3 seconds of receiving it
+        trigger_id=body["trigger_id"],
+        # View payload
+        view={ ... }
+    )
+
+# Pass a function to this method
+app.shortcut("open_modal")(open_modal)
+
+

Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload.
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def start(self,
port: int = 3000,
path: str = '/slack/events',
http_server_logger_enabled: bool = True) ‑> None
+
+
+
+ +Expand source code + +
def start(
+    self,
+    port: int = 3000,
+    path: str = "/slack/events",
+    http_server_logger_enabled: bool = True,
+) -> None:
+    """Starts a web server for local development.
+
+        # With the default settings, `http://localhost:3000/slack/events`
+        # is available for handling incoming requests from Slack
+        app.start()
+
+    This method internally starts a Web server process built with the `http.server` module.
+    For production, consider using a production-ready WSGI server such as Gunicorn.
+
+    Args:
+        port: The port to listen on (Default: 3000)
+        path: The path to handle request from Slack (Default: `/slack/events`)
+        http_server_logger_enabled: The flag to enable http.server logging if True (Default: True)
+    """
+    self._development_server = SlackAppDevelopmentServer(
+        port=port,
+        path=path,
+        app=self,
+        oauth_flow=self.oauth_flow,
+        http_server_logger_enabled=http_server_logger_enabled,
+    )
+    self._development_server.start()
+
+

Starts a web server for local development.

+
# With the default settings, `http://localhost:3000/slack/events`
+# is available for handling incoming requests from Slack
+app.start()
+
+

This method internally starts a Web server process built with the http.server module. +For production, consider using a production-ready WSGI server such as Gunicorn.

+

Args

+
+
port
+
The port to listen on (Default: 3000)
+
path
+
The path to handle request from Slack (Default: /slack/events)
+
http_server_logger_enabled
+
The flag to enable http.server logging if True (Default: True)
+
+
+
+def step(self,
callback_id: str | Pattern | WorkflowStep | WorkflowStepBuilder,
edit: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None,
save: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None,
execute: Callable[..., BoltResponse | None] | Listener | Sequence[Callable] | None = None)
+
+
+
+ +Expand source code + +
def step(
+    self,
+    callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder],
+    edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+    execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new step from app listener.
+
+    Unlike others, this method doesn't behave as a decorator.
+    If you want to register a step from app by a decorator, use `WorkflowStepBuilder`'s methods.
+
+        # Create a new WorkflowStep instance
+        from slack_bolt.workflows.step import WorkflowStep
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        # Pass Step to set up listeners
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    For further information about WorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        callback_id: The Callback ID for this step from app
+        edit: The function for displaying a modal in the Workflow Builder
+        save: The function for handling configuration in the Workflow Builder
+        execute: The function for handling the step execution
+    """
+    warnings.warn(
+        (
+            "Steps from apps for legacy workflows are now deprecated. "
+            "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/"
+        ),
+        category=DeprecationWarning,
+    )
+    step = callback_id
+    if isinstance(callback_id, (str, Pattern)):
+        step = WorkflowStep(
+            callback_id=callback_id,
+            edit=edit,  # type: ignore[arg-type]
+            save=save,  # type: ignore[arg-type]
+            execute=execute,  # type: ignore[arg-type]
+            base_logger=self._base_logger,
+        )
+    elif isinstance(step, WorkflowStepBuilder):
+        step = step.build(base_logger=self._base_logger)
+    elif not isinstance(step, WorkflowStep):
+        raise BoltError(f"Invalid step object ({type(step)})")
+
+    self.use(WorkflowStepMiddleware(step))
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new step from app listener.

+

Unlike others, this method doesn't behave as a decorator. +If you want to register a step from app by a decorator, use WorkflowStepBuilder's methods.

+
# Create a new WorkflowStep instance
+from slack_bolt.workflows.step import WorkflowStep
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+# Pass Step to set up listeners
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
callback_id
+
The Callback ID for this step from app
+
edit
+
The function for displaying a modal in the Workflow Builder
+
save
+
The function for handling configuration in the Workflow Builder
+
execute
+
The function for handling the step execution
+
+
+
+def use(self, *args) ‑> Callable | None +
+
+
+ +Expand source code + +
def use(self, *args) -> Optional[Callable]:
+    """Registers a new global middleware to this app. This method can be used as either a decorator or a method.
+
+    Refer to `App#middleware()` method's docstring for details."""
+    return self.middleware(*args)
+
+

Registers a new global middleware to this app. This method can be used as either a decorator or a method.

+

Refer to App#middleware() method's docstring for details.

+
+
+def view(self,
constraints: str | Pattern | Dict[str, str | Pattern],
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view(
+    self,
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_submission`/`view_closed` event listener.
+    This method can be used as either a decorator or a method.
+
+        # Use this method as a decorator
+        @app.view("view_1")
+        def handle_submission(ack, body, client, view):
+            # Assume there's an input block with `block_c` as the block_id and `dreamy_input`
+            hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+            user = body["user"]["id"]
+            # Validate the inputs
+            errors = {}
+            if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+                errors["block_c"] = "The value must be longer than 5 characters"
+            if len(errors) > 0:
+                ack(response_action="errors", errors=errors)
+                return
+            # Acknowledge the view_submission event and close the modal
+            ack()
+            # Do whatever you want with the input data - here we're saving it to a DB
+
+        # Pass a function to this method
+        app.view("view_1")(handle_submission)
+
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.
+
+    To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document.
+
+    Args:
+        constraints: The conditions that match a request payload
+        matchers: A list of listener matcher functions.
+            Only when all the matchers return True, the listener function can be invoked.
+        middleware: A list of lister middleware functions.
+            Only when all the middleware call `next()` method, the listener function can be invoked.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission/view_closed event listener. +This method can be used as either a decorator or a method.

+
# Use this method as a decorator
+@app.view("view_1")
+def handle_submission(ack, body, client, view):
+    # Assume there's an input block with <code>block\_c</code> as the block_id and <code>dreamy\_input</code>
+    hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"]
+    user = body["user"]["id"]
+    # Validate the inputs
+    errors = {}
+    if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5:
+        errors["block_c"] = "The value must be longer than 5 characters"
+    if len(errors) > 0:
+        ack(response_action="errors", errors=errors)
+        return
+    # Acknowledge the view_submission event and close the modal
+    ack()
+    # Do whatever you want with the input data - here we're saving it to a DB
+
+# Pass a function to this method
+app.view("view_1")(handle_submission)
+
+

Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads.

+

To learn available arguments for middleware/listeners, see slack_bolt.kwargs_injection.args's API document.

+

Args

+
+
constraints
+
The conditions that match a request payload
+
matchers
+
A list of listener matcher functions. +Only when all the matchers return True, the listener function can be invoked.
+
middleware
+
A list of lister middleware functions. +Only when all the middleware call next() method, the listener function can be invoked.
+
+
+
+def view_closed(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view_closed(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_closed` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details."""
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+ +
+
+def view_submission(self,
constraints: str | Pattern,
matchers: Sequence[Callable[..., bool]] | None = None,
middleware: Sequence[Callable | Middleware] | None = None) ‑> Callable[..., Callable[..., BoltResponse | None] | None]
+
+
+
+ +Expand source code + +
def view_submission(
+    self,
+    constraints: Union[str, Pattern],
+    matchers: Optional[Sequence[Callable[..., bool]]] = None,
+    middleware: Optional[Sequence[Union[Callable, Middleware]]] = None,
+) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]:
+    """Registers a new `view_submission` listener.
+    Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for
+    details.
+    """
+
+    def __call__(*args, **kwargs):
+        functions = self._to_listener_functions(kwargs) if kwargs else list(args)
+        primary_matcher = builtin_matchers.view_submission(constraints, base_logger=self._base_logger)
+        return self._register_listener(list(functions), primary_matcher, matchers, middleware)
+
+    return __call__
+
+

Registers a new view_submission listener. +Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for +details.

+
+
+
+
+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,
say_stream: SayStream | None = None,
next: Callable[[], None],
**kwargs)
+
+
+
+ +Expand source code + +
class Args:
+    """All the arguments in this class are available in any middleware / listeners.
+    You can inject the named variables in the argument list in arbitrary order.
+
+        @app.action("link_button")
+        def handle_buttons(ack, respond, logger, context, body, client):
+            logger.info(f"request body: {body}")
+            ack()
+            if context.channel_id is not None:
+                respond("Hi!")
+            client.views_open(
+                trigger_id=body["trigger_id"],
+                view={ ... }
+            )
+
+    Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class.
+
+        @app.action("link_button")
+        def handle_buttons(args):
+            args.logger.info(f"request body: {args.body}")
+            args.ack()
+            if args.context.channel_id is not None:
+                args.respond("Hi!")
+            args.client.views_open(
+                trigger_id=args.body["trigger_id"],
+                view={ ... }
+            )
+
+    """
+
+    client: WebClient
+    """`slack_sdk.web.WebClient` instance with a valid token"""
+    logger: Logger
+    """Logger instance"""
+    req: BoltRequest
+    """Incoming request from Slack"""
+    resp: BoltResponse
+    """Response representation"""
+    request: BoltRequest
+    """Incoming request from Slack"""
+    response: BoltResponse
+    """Response representation"""
+    context: BoltContext
+    """Context data associated with the incoming request"""
+    body: Dict[str, Any]
+    """Parsed request body data"""
+    # payload
+    payload: Dict[str, Any]
+    """The unwrapped core data in the request body"""
+    options: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.options` listener"""
+    shortcut: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.shortcut` listener"""
+    action: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.action` listener"""
+    view: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.view` listener"""
+    command: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.command` listener"""
+    event: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.event` listener"""
+    message: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.message` listener"""
+    # utilities
+    ack: Ack
+    """`ack()` utility function, which returns acknowledgement to the Slack servers"""
+    say: Say
+    """`say()` utility function, which calls `chat.postMessage` API with the associated channel ID"""
+    respond: Respond
+    """`respond()` utility function, which utilizes the associated `response_url`"""
+    complete: Complete
+    """`complete()` utility function, signals a successful completion of the custom function"""
+    fail: Fail
+    """`fail()` utility function, signal that the custom function failed to complete"""
+    set_status: Optional[SetStatus]
+    """`set_status()` utility function for AI Agents & Assistants"""
+    set_title: Optional[SetTitle]
+    """`set_title()` utility function for AI Agents & Assistants"""
+    set_suggested_prompts: Optional[SetSuggestedPrompts]
+    """`set_suggested_prompts()` utility function for AI Agents & Assistants"""
+    get_thread_context: Optional[GetThreadContext]
+    """`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"""
+    next_: Callable[[], None]
+    """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions"""
+
+    def __init__(
+        self,
+        *,
+        logger: logging.Logger,
+        client: WebClient,
+        req: BoltRequest,
+        resp: BoltResponse,
+        context: BoltContext,
+        body: Dict[str, Any],
+        payload: Dict[str, Any],
+        options: Optional[Dict[str, Any]] = None,
+        shortcut: Optional[Dict[str, Any]] = None,
+        action: Optional[Dict[str, Any]] = None,
+        view: Optional[Dict[str, Any]] = None,
+        command: Optional[Dict[str, Any]] = None,
+        event: Optional[Dict[str, Any]] = None,
+        message: Optional[Dict[str, Any]] = None,
+        ack: Ack,
+        say: Say,
+        respond: Respond,
+        complete: Complete,
+        fail: Fail,
+        set_status: Optional[SetStatus] = None,
+        set_title: Optional[SetTitle] = None,
+        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
+        next: Callable[[], None],
+        **kwargs,  # noqa
+    ):
+        self.logger: logging.Logger = logger
+        self.client: WebClient = client
+        self.request = self.req = req
+        self.response = self.resp = resp
+        self.context: BoltContext = context
+
+        self.body: Dict[str, Any] = body
+        self.payload: Dict[str, Any] = payload
+        self.options: Optional[Dict[str, Any]] = options
+        self.shortcut: Optional[Dict[str, Any]] = shortcut
+        self.action: Optional[Dict[str, Any]] = action
+        self.view: Optional[Dict[str, Any]] = view
+        self.command: Optional[Dict[str, Any]] = command
+        self.event: Optional[Dict[str, Any]] = event
+        self.message: Optional[Dict[str, Any]] = message
+
+        self.ack: Ack = ack
+        self.say: Say = say
+        self.respond: Respond = respond
+        self.complete: Complete = complete
+        self.fail: Fail = fail
+
+        self.set_status = set_status
+        self.set_title = set_title
+        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
+
+

All the arguments in this class are available in any middleware / listeners. +You can inject the named variables in the argument list in arbitrary order.

+
@app.action("link_button")
+def handle_buttons(ack, respond, logger, context, body, client):
+    logger.info(f"request body: {body}")
+    ack()
+    if context.channel_id is not None:
+        respond("Hi!")
+    client.views_open(
+        trigger_id=body["trigger_id"],
+        view={ ... }
+    )
+
+

Alternatively, you can include a parameter named args and it will be injected with an instance of this class.

+
@app.action("link_button")
+def handle_buttons(args):
+    args.logger.info(f"request body: {args.body}")
+    args.ack()
+    if args.context.channel_id is not None:
+        args.respond("Hi!")
+    args.client.views_open(
+        trigger_id=args.body["trigger_id"],
+        view={ ... }
+    )
+
+

Class variables

+
+
var ackAck
+
+

ack() utility function, which returns acknowledgement to the Slack servers

+
+
var action : Dict[str, Any] | None
+
+

An alias for payload in an @app.action listener

+
+
var body : Dict[str, Any]
+
+

Parsed request body data

+
+
var client : slack_sdk.web.client.WebClient
+
+

slack_sdk.web.WebClient instance with a valid token

+
+
var command : Dict[str, Any] | None
+
+

An alias for payload in an @app.command listener

+
+
var completeComplete
+
+

complete() utility function, signals a successful completion of the custom function

+
+
var contextBoltContext
+
+

Context data associated with the incoming request

+
+
var event : Dict[str, Any] | None
+
+

An alias for payload in an @app.event listener

+
+
var failFail
+
+

fail() utility function, signal that the custom function failed to complete

+
+
var get_thread_contextGetThreadContext | None
+
+

get_thread_context() utility function for AI Agents & Assistants

+
+
var logger : logging.Logger
+
+

Logger instance

+
+
var message : Dict[str, Any] | None
+
+

An alias for payload in an @app.message listener

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

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

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

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

+
+
var options : Dict[str, Any] | None
+
+

An alias for payload in an @app.options listener

+
+
var payload : Dict[str, Any]
+
+

The unwrapped core data in the request body

+
+
var reqBoltRequest
+
+

Incoming request from Slack

+
+
var requestBoltRequest
+
+

Incoming request from Slack

+
+
var respBoltResponse
+
+

Response representation

+
+
var respondRespond
+
+

respond() utility function, which utilizes the associated response_url

+
+
var responseBoltResponse
+
+

Response representation

+
+
var save_thread_contextSaveThreadContext | None
+
+

save_thread_context() utility function for AI Agents & Assistants

+
+
var saySay
+
+

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

+
+
var set_suggested_promptsSetSuggestedPrompts | None
+
+

set_suggested_prompts() utility function for AI Agents & Assistants

+
+
var set_titleSetTitle | None
+
+

set_title() utility function for AI Agents & Assistants

+
+
var shortcut : Dict[str, Any] | None
+
+

An alias for payload in an @app.shortcut listener

+
+
var view : Dict[str, Any] | None
+
+

An alias for payload in an @app.view listener

+
+
+
+
+class Assistant +(*,
app_name: str = 'assistant',
thread_context_store: AssistantThreadContextStore | None = None,
logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class Assistant(Middleware):
+    _thread_started_listeners: Optional[List[Listener]]
+    _thread_context_changed_listeners: Optional[List[Listener]]
+    _user_message_listeners: Optional[List[Listener]]
+    _bot_message_listeners: Optional[List[Listener]]
+
+    thread_context_store: Optional[AssistantThreadContextStore]
+    base_logger: Optional[logging.Logger]
+
+    def __init__(
+        self,
+        *,
+        app_name: str = "assistant",
+        thread_context_store: Optional[AssistantThreadContextStore] = None,
+        logger: Optional[logging.Logger] = None,
+    ):
+        self.app_name = app_name
+        self.thread_context_store = thread_context_store
+        self.base_logger = logger
+
+        self._thread_started_listeners = None
+        self._thread_context_changed_listeners = None
+        self._user_message_listeners = None
+        self._bot_message_listeners = None
+
+    def thread_started(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_started_listeners is None:
+            self._thread_started_listeners = []
+        all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def user_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._user_message_listeners is None:
+            self._user_message_listeners = []
+        all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def bot_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._bot_message_listeners is None:
+            self._bot_message_listeners = []
+        all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def thread_context_changed(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_context_changed_listeners is None:
+            self._thread_context_changed_listeners = []
+        all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def _merge_matchers(
+        self,
+        primary_matcher: Callable[..., bool],
+        custom_matchers: Optional[Union[Callable[..., bool], ListenerMatcher]],
+    ):
+        return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + (
+            custom_matchers or []
+        )  # 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]
+        self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]
+    ) -> Optional[BoltResponse]:
+        if self._thread_context_changed_listeners is None:
+            self.thread_context_changed(self.default_thread_context_changed)
+
+        listener_runner: ThreadListenerRunner = req.context.listener_runner
+        for listeners in [
+            self._thread_started_listeners,
+            self._thread_context_changed_listeners,
+            self._user_message_listeners,
+            self._bot_message_listeners,
+        ]:
+            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,
+                            listener_name="assistant_listener",
+                            listener=listener,
+                        )
+        if is_other_message_sub_event_in_assistant_thread(req.body):
+            # message_changed, message_deleted, etc.
+            return req.context.ack()
+
+        next()
+
+    def build_listener(
+        self,
+        listener_or_functions: Union[Listener, Callable, List[Callable]],
+        matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None,
+        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, 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)
+
+            matchers = matchers if matchers else []
+            listener_matchers: List[ListenerMatcher] = []
+            for matcher in matchers:
+                if isinstance(matcher, ListenerMatcher):
+                    listener_matchers.append(matcher)
+                elif isinstance(matcher, Callable):  # type: ignore[arg-type]
+                    listener_matchers.append(
+                        build_listener_matcher(
+                            func=matcher,
+                            asyncio=False,
+                            base_logger=base_logger,
+                        )
+                    )
+            return CustomListener(
+                app_name=self.app_name,
+                matchers=listener_matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=True,
+                base_logger=base_logger or self.base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+

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

+

Ancestors

+ +

Class variables

+
+
var base_logger : logging.Logger | None
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore | None
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def default_thread_context_changed(save_thread_context: SaveThreadContext,
payload: dict)
+
+
+
+ +Expand source code + +
@staticmethod
+def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict):
+    save_thread_context(payload["assistant_thread"]["context"])
+
+
+
+
+

Methods

+
+
+def bot_message(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def bot_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._bot_message_listeners is None:
+        self._bot_message_listeners = []
+    all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def build_listener(self,
listener_or_functions: Listener | Callable | List[Callable],
matchers: List[ListenerMatcher | Callable[..., bool]] | None = None,
middleware: List[Middleware] | None = None,
base_logger: logging.Logger | None = None) ‑> Listener
+
+
+
+ +Expand source code + +
def build_listener(
+    self,
+    listener_or_functions: Union[Listener, Callable, List[Callable]],
+    matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None,
+    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, 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)
+
+        matchers = matchers if matchers else []
+        listener_matchers: List[ListenerMatcher] = []
+        for matcher in matchers:
+            if isinstance(matcher, ListenerMatcher):
+                listener_matchers.append(matcher)
+            elif isinstance(matcher, Callable):  # type: ignore[arg-type]
+                listener_matchers.append(
+                    build_listener_matcher(
+                        func=matcher,
+                        asyncio=False,
+                        base_logger=base_logger,
+                    )
+                )
+        return CustomListener(
+            app_name=self.app_name,
+            matchers=listener_matchers,
+            middleware=middleware,
+            ack_function=ack_function,
+            lazy_functions=functions,
+            auto_acknowledgement=True,
+            base_logger=base_logger or self.base_logger,
+        )
+    else:
+        raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+
+
+
+def thread_context_changed(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_context_changed(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_context_changed_listeners is None:
+        self._thread_context_changed_listeners = []
+    all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def thread_started(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_started(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_started_listeners is None:
+        self._thread_started_listeners = []
+    all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def user_message(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def user_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._user_message_listeners is None:
+        self._user_message_listeners = []
+    all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+

Inherited members

+ +
+
+class AssistantThreadContext +(payload: dict) +
+
+
+ +Expand source code + +
class AssistantThreadContext(dict):
+    enterprise_id: Optional[str]
+    team_id: Optional[str]
+    channel_id: str
+
+    def __init__(self, payload: dict):
+        dict.__init__(self, **payload)
+        self.enterprise_id = payload.get("enterprise_id")
+        self.team_id = payload.get("team_id")
+        self.channel_id = payload["channel_id"]
+
+

dict() -> new empty dictionary +dict(mapping) -> new dictionary initialized from a mapping object's +(key, value) pairs +dict(iterable) -> new dictionary initialized as if via: +d = {} +for k, v in iterable: +d[k] = v +dict(**kwargs) -> new dictionary initialized with the name=value pairs +in the keyword argument list. +For example: +dict(one=1, two=2)

+

Ancestors

+
    +
  • builtins.dict
  • +
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var enterprise_id : str | None
+
+

The type of the None singleton.

+
+
var team_id : str | None
+
+

The type of the None singleton.

+
+
+
+
+class AssistantThreadContextStore +
+
+
+ +Expand source code + +
class AssistantThreadContextStore:
+    def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+        raise NotImplementedError()
+
+    def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+def find(self, *, channel_id: str, thread_ts: str) ‑> AssistantThreadContext | None +
+
+
+ +Expand source code + +
def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+    raise NotImplementedError()
+
+
+
+
+def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) ‑> None +
+
+
+ +Expand source code + +
def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+    raise NotImplementedError()
+
+
+
+
+
+
+class BoltContext +(*args, **kwargs) +
+
+
+ +Expand source code + +
class BoltContext(BaseContext):
+    """Context object associated with a request from Slack."""
+
+    def to_copyable(self) -> "BoltContext":
+        new_dict = {}
+        for prop_name, prop_value in self.items():
+            if prop_name in self.copyable_standard_property_names:
+                # all the standard properties are copiable
+                new_dict[prop_name] = prop_value
+            elif prop_name in self.non_copyable_standard_property_names:
+                # Do nothing with this property (e.g., listener_runner)
+                continue
+            else:
+                try:
+                    copied_value = create_copy(prop_value)
+                    new_dict[prop_name] = copied_value
+                except TypeError as te:
+                    self.logger.warning(
+                        f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                        "due to a deep-copy creation error. Consider passing the value not as part of context object "
+                        f"(error: {te})"
+                    )
+        return BoltContext(new_dict)
+
+    # The return type is intentionally string to avoid circular imports
+    @property
+    def listener_runner(self) -> "ThreadListenerRunner":
+        """The properly configured listener_runner that is available for middleware/listeners."""
+        return self["listener_runner"]
+
+    @property
+    def client(self) -> WebClient:
+        """The `WebClient` instance available for this request.
+
+            @app.event("app_mention")
+            def handle_events(context):
+                context.client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+            # You can access "client" this way too.
+            @app.event("app_mention")
+            def handle_events(client, context):
+                client.chat_postMessage(
+                    channel=context.channel_id,
+                    text="Thanks!",
+                )
+
+        Returns:
+            `WebClient` instance
+        """
+        if "client" not in self:
+            self["client"] = WebClient(token=None)
+        return self["client"]
+
+    @property
+    def ack(self) -> Ack:
+        """`ack()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack):
+                ack()
+
+        Returns:
+            Callable `ack()` function
+        """
+        if "ack" not in self:
+            self["ack"] = Ack()
+        return self["ack"]
+
+    @property
+    def say(self) -> Say:
+        """`say()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+                context.say("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack, say):
+                ack()
+                say("Hi!")
+
+        Returns:
+            Callable `say()` function
+        """
+        if "say" not in self:
+            self["say"] = Say(client=self.client, channel=self.channel_id)
+        return self["say"]
+
+    @property
+    def respond(self) -> Optional[Respond]:
+        """`respond()` function for this request.
+
+            @app.action("button")
+            def handle_button_clicks(context):
+                context.ack()
+                context.respond("Hi!")
+
+            # You can access "ack" this way too.
+            @app.action("button")
+            def handle_button_clicks(ack, respond):
+                ack()
+                respond("Hi!")
+
+        Returns:
+            Callable `respond()` function
+        """
+        if "respond" not in self:
+            self["respond"] = Respond(
+                response_url=self.response_url,
+                proxy=self.client.proxy,
+                ssl=self.client.ssl,
+            )
+        return self["respond"]
+
+    @property
+    def complete(self) -> Complete:
+        """`complete()` function for this request. Once a custom function's state is set to complete,
+        any outputs the function returns will be passed along to the next step of its housing workflow,
+        or complete the workflow if the function is the last step in a workflow. Additionally,
+        any interactivity handlers associated to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            def handle_button_clicks(ack, complete):
+                ack()
+                complete(outputs={"stringReverse":"olleh"})
+
+            @app.function("reverse")
+            def handle_button_clicks(context):
+                context.ack()
+                context.complete(outputs={"stringReverse":"olleh"})
+
+        Returns:
+            Callable `complete()` function
+        """
+        if "complete" not in self:
+            self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id)
+        return self["complete"]
+
+    @property
+    def fail(self) -> Fail:
+        """`fail()` function for this request. Once a custom function's state is set to error,
+        its housing workflow will be interrupted and any provided error message will be passed
+        on to the end user through SlackBot. Additionally, any interactivity handlers associated
+        to a function invocation will no longer be invocable.
+
+            @app.function("reverse")
+            def handle_button_clicks(ack, fail):
+                ack()
+                fail(error="something went wrong")
+
+            @app.function("reverse")
+            def handle_button_clicks(context):
+                context.ack()
+                context.fail(error="something went wrong")
+
+        Returns:
+            Callable `fail()` function
+        """
+        if "fail" not in self:
+            self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id)
+        return self["fail"]
+
+    @property
+    def set_title(self) -> Optional[SetTitle]:
+        return self.get("set_title")
+
+    @property
+    def set_status(self) -> Optional[SetStatus]:
+        return self.get("set_status")
+
+    @property
+    def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
+        return self.get("set_suggested_prompts")
+
+    @property
+    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")
+
+

Context object associated with a request from Slack.

+

Ancestors

+ +

Instance variables

+
+
prop ackAck
+
+
+ +Expand source code + +
@property
+def ack(self) -> Ack:
+    """`ack()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack):
+            ack()
+
+    Returns:
+        Callable `ack()` function
+    """
+    if "ack" not in self:
+        self["ack"] = Ack()
+    return self["ack"]
+
+

ack() function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack):
+    ack()
+
+

Returns

+

Callable ack() function

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    """The `WebClient` instance available for this request.
+
+        @app.event("app_mention")
+        def handle_events(context):
+            context.client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+        # You can access "client" this way too.
+        @app.event("app_mention")
+        def handle_events(client, context):
+            client.chat_postMessage(
+                channel=context.channel_id,
+                text="Thanks!",
+            )
+
+    Returns:
+        `WebClient` instance
+    """
+    if "client" not in self:
+        self["client"] = WebClient(token=None)
+    return self["client"]
+
+

The WebClient instance available for this request.

+
@app.event("app_mention")
+def handle_events(context):
+    context.client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+# You can access "client" this way too.
+@app.event("app_mention")
+def handle_events(client, context):
+    client.chat_postMessage(
+        channel=context.channel_id,
+        text="Thanks!",
+    )
+
+

Returns

+

WebClient instance

+
+
prop completeComplete
+
+
+ +Expand source code + +
@property
+def complete(self) -> Complete:
+    """`complete()` function for this request. Once a custom function's state is set to complete,
+    any outputs the function returns will be passed along to the next step of its housing workflow,
+    or complete the workflow if the function is the last step in a workflow. Additionally,
+    any interactivity handlers associated to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        def handle_button_clicks(ack, complete):
+            ack()
+            complete(outputs={"stringReverse":"olleh"})
+
+        @app.function("reverse")
+        def handle_button_clicks(context):
+            context.ack()
+            context.complete(outputs={"stringReverse":"olleh"})
+
+    Returns:
+        Callable `complete()` function
+    """
+    if "complete" not in self:
+        self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id)
+    return self["complete"]
+
+

complete() function for this request. Once a custom function's state is set to complete, +any outputs the function returns will be passed along to the next step of its housing workflow, +or complete the workflow if the function is the last step in a workflow. Additionally, +any interactivity handlers associated to a function invocation will no longer be invocable.

+
@app.function("reverse")
+def handle_button_clicks(ack, complete):
+    ack()
+    complete(outputs={"stringReverse":"olleh"})
+
+@app.function("reverse")
+def handle_button_clicks(context):
+    context.ack()
+    context.complete(outputs={"stringReverse":"olleh"})
+
+

Returns

+

Callable complete() function

+
+
prop failFail
+
+
+ +Expand source code + +
@property
+def fail(self) -> Fail:
+    """`fail()` function for this request. Once a custom function's state is set to error,
+    its housing workflow will be interrupted and any provided error message will be passed
+    on to the end user through SlackBot. Additionally, any interactivity handlers associated
+    to a function invocation will no longer be invocable.
+
+        @app.function("reverse")
+        def handle_button_clicks(ack, fail):
+            ack()
+            fail(error="something went wrong")
+
+        @app.function("reverse")
+        def handle_button_clicks(context):
+            context.ack()
+            context.fail(error="something went wrong")
+
+    Returns:
+        Callable `fail()` function
+    """
+    if "fail" not in self:
+        self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id)
+    return self["fail"]
+
+

fail() function for this request. Once a custom function's state is set to error, +its housing workflow will be interrupted and any provided error message will be passed +on to the end user through SlackBot. Additionally, any interactivity handlers associated +to a function invocation will no longer be invocable.

+
@app.function("reverse")
+def handle_button_clicks(ack, fail):
+    ack()
+    fail(error="something went wrong")
+
+@app.function("reverse")
+def handle_button_clicks(context):
+    context.ack()
+    context.fail(error="something went wrong")
+
+

Returns

+

Callable fail() function

+
+
prop get_thread_contextGetThreadContext | None
+
+
+ +Expand source code + +
@property
+def get_thread_context(self) -> Optional[GetThreadContext]:
+    return self.get("get_thread_context")
+
+
+
+
prop listener_runner : ThreadListenerRunner
+
+
+ +Expand source code + +
@property
+def listener_runner(self) -> "ThreadListenerRunner":
+    """The properly configured listener_runner that is available for middleware/listeners."""
+    return self["listener_runner"]
+
+

The properly configured listener_runner that is available for middleware/listeners.

+
+
prop respondRespond | None
+
+
+ +Expand source code + +
@property
+def respond(self) -> Optional[Respond]:
+    """`respond()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+            context.respond("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack, respond):
+            ack()
+            respond("Hi!")
+
+    Returns:
+        Callable `respond()` function
+    """
+    if "respond" not in self:
+        self["respond"] = Respond(
+            response_url=self.response_url,
+            proxy=self.client.proxy,
+            ssl=self.client.ssl,
+        )
+    return self["respond"]
+
+

respond() function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+    context.respond("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack, respond):
+    ack()
+    respond("Hi!")
+
+

Returns

+

Callable respond() function

+
+
prop save_thread_contextSaveThreadContext | None
+
+
+ +Expand source code + +
@property
+def save_thread_context(self) -> Optional[SaveThreadContext]:
+    return self.get("save_thread_context")
+
+
+
+
prop saySay
+
+
+ +Expand source code + +
@property
+def say(self) -> Say:
+    """`say()` function for this request.
+
+        @app.action("button")
+        def handle_button_clicks(context):
+            context.ack()
+            context.say("Hi!")
+
+        # You can access "ack" this way too.
+        @app.action("button")
+        def handle_button_clicks(ack, say):
+            ack()
+            say("Hi!")
+
+    Returns:
+        Callable `say()` function
+    """
+    if "say" not in self:
+        self["say"] = Say(client=self.client, channel=self.channel_id)
+    return self["say"]
+
+

say() function for this request.

+
@app.action("button")
+def handle_button_clicks(context):
+    context.ack()
+    context.say("Hi!")
+
+# You can access "ack" this way too.
+@app.action("button")
+def handle_button_clicks(ack, say):
+    ack()
+    say("Hi!")
+
+

Returns

+

Callable say() function

+
+
prop say_streamSayStream | None
+
+
+ +Expand source code + +
@property
+def say_stream(self) -> Optional[SayStream]:
+    return self.get("say_stream")
+
+
+
+
prop set_statusSetStatus | None
+
+
+ +Expand source code + +
@property
+def set_status(self) -> Optional[SetStatus]:
+    return self.get("set_status")
+
+
+
+
prop set_suggested_promptsSetSuggestedPrompts | None
+
+
+ +Expand source code + +
@property
+def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
+    return self.get("set_suggested_prompts")
+
+
+
+
prop set_titleSetTitle | None
+
+
+ +Expand source code + +
@property
+def set_title(self) -> Optional[SetTitle]:
+    return self.get("set_title")
+
+
+
+
+

Methods

+
+
+def to_copyable(self) ‑> BoltContext +
+
+
+ +Expand source code + +
def to_copyable(self) -> "BoltContext":
+    new_dict = {}
+    for prop_name, prop_value in self.items():
+        if prop_name in self.copyable_standard_property_names:
+            # all the standard properties are copiable
+            new_dict[prop_name] = prop_value
+        elif prop_name in self.non_copyable_standard_property_names:
+            # Do nothing with this property (e.g., listener_runner)
+            continue
+        else:
+            try:
+                copied_value = create_copy(prop_value)
+                new_dict[prop_name] = copied_value
+            except TypeError as te:
+                self.logger.warning(
+                    f"Skipped setting '{prop_name}' to a copied request for lazy listeners "
+                    "due to a deep-copy creation error. Consider passing the value not as part of context object "
+                    f"(error: {te})"
+                )
+    return BoltContext(new_dict)
+
+
+
+
+

Inherited members

+ +
+
+class BoltRequest +(*,
body: str | dict,
query: str | Dict[str, str] | Dict[str, Sequence[str]] | None = None,
headers: Dict[str, str | Sequence[str]] | None = None,
context: Dict[str, Any] | None = None,
mode: str = 'http')
+
+
+
+ +Expand source code + +
class BoltRequest:
+    raw_body: str
+    query: Dict[str, Sequence[str]]
+    headers: Dict[str, Sequence[str]]
+    content_type: Optional[str]
+    body: Dict[str, Any]
+    context: BoltContext
+    lazy_only: bool
+    lazy_function_name: Optional[str]
+    mode: str  # either "http" or "socket_mode"
+
+    def __init__(
+        self,
+        *,
+        body: Union[str, dict],
+        query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None,
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+        context: Optional[Dict[str, Any]] = None,
+        mode: str = "http",  # either "http" or "socket_mode"
+    ):
+        """Request to a Bolt app.
+
+        Args:
+            body: The raw request body (only plain text is supported for "http" mode)
+            query: The query string data in any data format.
+            headers: The request headers.
+            context: The context in this request.
+            mode: The mode used for this request. (either "http" or "socket_mode")
+        """
+        if mode == "http":
+            # HTTP Mode
+            if body is not None and not isinstance(body, str):
+                raise BoltError(error_message_raw_body_required_in_http_mode())
+            self.raw_body = body if body is not None else ""
+        else:
+            # Socket Mode
+            if body is not None and isinstance(body, str):
+                self.raw_body = body
+            else:
+                # We don't convert the dict value to str
+                # as doing so does not guarantee to keep the original structure/format.
+                self.raw_body = ""
+
+        self.query = parse_query(query)
+        self.headers = build_normalized_headers(headers)
+        self.content_type = extract_content_type(self.headers)
+
+        if isinstance(body, str):
+            self.body = parse_body(self.raw_body, self.content_type)
+        elif isinstance(body, dict):
+            self.body = body
+        else:
+            self.body = {}
+
+        self.context = build_context(BoltContext(context if context else {}), self.body)
+        self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0])
+        self.lazy_function_name = self.headers.get("x-slack-bolt-lazy-function-name", [None])[0]
+        self.mode = mode
+
+    def to_copyable(self) -> "BoltRequest":
+        body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+        return BoltRequest(
+            body=body,
+            query=self.query,
+            headers=self.headers,
+            context=self.context.to_copyable(),
+            mode=self.mode,
+        )
+
+

Request to a Bolt app.

+

Args

+
+
body
+
The raw request body (only plain text is supported for "http" mode)
+
query
+
The query string data in any data format.
+
headers
+
The request headers.
+
context
+
The context in this request.
+
mode
+
The mode used for this request. (either "http" or "socket_mode")
+
+

Class variables

+
+
var body : Dict[str, Any]
+
+

The type of the None singleton.

+
+
var content_type : str | None
+
+

The type of the None singleton.

+
+
var contextBoltContext
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var lazy_function_name : str | None
+
+

The type of the None singleton.

+
+
var lazy_only : bool
+
+

The type of the None singleton.

+
+
var mode : str
+
+

The type of the None singleton.

+
+
var query : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var raw_body : str
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def to_copyable(self) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_copyable(self) -> "BoltRequest":
+    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+    return BoltRequest(
+        body=body,
+        query=self.query,
+        headers=self.headers,
+        context=self.context.to_copyable(),
+        mode=self.mode,
+    )
+
+
+
+
+
+
+class BoltResponse +(*,
status: int,
body: str | dict = '',
headers: Dict[str, str | Sequence[str]] | None = None)
+
+
+
+ +Expand source code + +
class BoltResponse:
+    status: int
+    body: str
+    headers: Dict[str, Sequence[str]]
+
+    def __init__(
+        self,
+        *,
+        status: int,
+        body: Union[str, dict] = "",
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+    ):
+        """The response from a Bolt app.
+
+        Args:
+            status: HTTP status code
+            body: The response body (dict and str are supported)
+            headers: The response headers.
+        """
+        self.status: int = status
+        self.body: str = json.dumps(body) if isinstance(body, dict) else body
+        self.headers: Dict[str, Sequence[str]] = {}
+        if headers is not None:
+            for name, value in headers.items():
+                if value is None:
+                    continue
+                if isinstance(value, list):
+                    self.headers[name.lower()] = value
+                elif isinstance(value, set):
+                    self.headers[name.lower()] = list(value)
+                else:
+                    self.headers[name.lower()] = [str(value)]
+
+        if "content-type" not in self.headers.keys():
+            if self.body and self.body.startswith("{"):
+                self.headers["content-type"] = ["application/json;charset=utf-8"]
+            else:
+                self.headers["content-type"] = ["text/plain;charset=utf-8"]
+
+    def first_headers(self) -> Dict[str, str]:
+        return {k: list(v)[0] for k, v in self.headers.items()}
+
+    def first_headers_without_set_cookie(self) -> Dict[str, str]:
+        return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"}
+
+    def cookies(self) -> Sequence[SimpleCookie]:
+        header_values = self.headers.get("set-cookie", [])
+        return [self._to_simple_cookie(v) for v in header_values]
+
+    @staticmethod
+    def _to_simple_cookie(header_value: str) -> SimpleCookie:
+        c = SimpleCookie()
+        c.load(header_value)
+        return c
+
+

The response from a Bolt app.

+

Args

+
+
status
+
HTTP status code
+
body
+
The response body (dict and str are supported)
+
headers
+
The response headers.
+
+

Class variables

+
+
var body : str
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var status : int
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def cookies(self) ‑> Sequence[http.cookies.SimpleCookie] +
+
+
+ +Expand source code + +
def cookies(self) -> Sequence[SimpleCookie]:
+    header_values = self.headers.get("set-cookie", [])
+    return [self._to_simple_cookie(v) for v in header_values]
+
+
+
+
+def first_headers(self) ‑> Dict[str, str] +
+
+
+ +Expand source code + +
def first_headers(self) -> Dict[str, str]:
+    return {k: list(v)[0] for k, v in self.headers.items()}
+
+
+
+ +
+
+ +Expand source code + +
def first_headers_without_set_cookie(self) -> Dict[str, str]:
+    return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"}
+
+
+
+
+
+
+class Complete +(client: slack_sdk.web.client.WebClient, function_execution_id: str | None) +
+
+
+ +Expand source code + +
class Complete:
+    client: WebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: WebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse:
+        """Signal the successful completion of the custom function.
+
+        Kwargs:
+            outputs: Json serializable object containing the output values
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("complete is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {})
+
+    def has_been_called(self) -> bool:
+        """Check if this complete function has been called.
+
+        Returns:
+            bool: True if the complete function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this complete function has been called.
+
+    Returns:
+        bool: True if the complete function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this complete function has been called.

+

Returns

+
+
bool
+
True if the complete function has been called, False otherwise.
+
+
+
+
+
+class CustomListenerMatcher +(*,
app_name: str,
func: Callable[..., bool],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class CustomListenerMatcher(ListenerMatcher):
+    app_name: str
+    func: Callable[..., bool]
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable[..., bool], base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+        return self.func(
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., bool]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class Fail +(client: slack_sdk.web.client.WebClient, function_execution_id: str | None) +
+
+
+ +Expand source code + +
class Fail:
+    client: WebClient
+    function_execution_id: Optional[str]
+    _called: bool
+
+    def __init__(
+        self,
+        client: WebClient,
+        function_execution_id: Optional[str],
+    ):
+        self.client = client
+        self.function_execution_id = function_execution_id
+        self._called = False
+
+    def __call__(self, error: str) -> SlackResponse:
+        """Signal that the custom function failed to complete.
+
+        Kwargs:
+            error: Error message to return to slack
+
+        Returns:
+            SlackResponse: The response object returned from slack
+
+        Raises:
+            ValueError: If this function cannot be used.
+        """
+        if self.function_execution_id is None:
+            raise ValueError("fail is unsupported here as there is no function_execution_id")
+
+        self._called = True
+        return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error)
+
+    def has_been_called(self) -> bool:
+        """Check if this fail function has been called.
+
+        Returns:
+            bool: True if the fail function has been called, False otherwise.
+        """
+        return self._called
+
+
+

Class variables

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var function_execution_id : str | None
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def has_been_called(self) ‑> bool +
+
+
+ +Expand source code + +
def has_been_called(self) -> bool:
+    """Check if this fail function has been called.
+
+    Returns:
+        bool: True if the fail function has been called, False otherwise.
+    """
+    return self._called
+
+

Check if this fail function has been called.

+

Returns

+
+
bool
+
True if the fail function has been called, False otherwise.
+
+
+
+
+
+class FileAssistantThreadContextStore +(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts') +
+
+
+ +Expand source code + +
class FileAssistantThreadContextStore(AssistantThreadContextStore):
+
+    def __init__(
+        self,
+        base_dir: str = str(Path.home()) + "/.bolt-app-assistant-thread-contexts",
+    ):
+        self.base_dir = base_dir
+        self._mkdir(self.base_dir)
+
+    def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+        path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+        with open(path, "w") as f:
+            f.write(json.dumps(context))
+
+    def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+        path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+        try:
+            with open(path) as f:
+                data = json.loads(f.read())
+                if data.get("channel_id") is not None:
+                    return AssistantThreadContext(data)
+        except FileNotFoundError:
+            pass
+        return None
+
+    @staticmethod
+    def _mkdir(path: Union[str, Path]):
+        if isinstance(path, str):
+            path = Path(path)
+        path.mkdir(parents=True, exist_ok=True)
+
+
+

Ancestors

+ +

Methods

+
+
+def find(self, *, channel_id: str, thread_ts: str) ‑> AssistantThreadContext | None +
+
+
+ +Expand source code + +
def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]:
+    path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+    try:
+        with open(path) as f:
+            data = json.loads(f.read())
+            if data.get("channel_id") is not None:
+                return AssistantThreadContext(data)
+    except FileNotFoundError:
+        pass
+    return None
+
+
+
+
+def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) ‑> None +
+
+
+ +Expand source code + +
def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None:
+    path = f"{self.base_dir}/{channel_id}-{thread_ts}.json"
+    with open(path, "w") as f:
+        f.write(json.dumps(context))
+
+
+
+
+
+
+class Listener +
+
+
+ +Expand source code + +
class Listener(metaclass=ABCMeta):
+    matchers: Sequence[ListenerMatcher]
+    middleware: Sequence[Middleware]
+    ack_function: Callable[..., BoltResponse]
+    lazy_functions: Sequence[Callable[..., None]]
+    auto_acknowledgement: bool
+    ack_timeout: int = 3
+
+    def matches(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> bool:
+        is_matched: bool = False
+        for matcher in self.matchers:
+            is_matched = matcher.matches(req, resp)
+            if not is_matched:
+                return is_matched
+        return is_matched
+
+    def run_middleware(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> Tuple[Optional[BoltResponse], bool]:
+        """Runs a middleware.
+
+        Args:
+            req: The incoming request
+            resp: The current response
+
+        Returns:
+            A tuple of the processed response and a flag indicating termination
+        """
+        for m in self.middleware:
+            middleware_state = {"next_called": False}
+
+            def next_():
+                middleware_state["next_called"] = True
+
+            resp = m.process(req=req, resp=resp, next=next_)  # type: ignore[assignment]
+            if not middleware_state["next_called"]:
+                # next() was not called in this middleware
+                return (resp, True)
+        return (resp, False)
+
+    @abstractmethod
+    def run_ack_function(self, *, request: BoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+        """Runs all the registered middleware and then run the listener function.
+
+        Args:
+            request: The incoming request
+            response: The current response
+
+        Returns:
+            The processed response
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Class variables

+
+
var ack_function : Callable[..., BoltResponse]
+
+

The type of the None singleton.

+
+
var ack_timeout : int
+
+

The type of the None singleton.

+
+
var auto_acknowledgement : bool
+
+

The type of the None singleton.

+
+
var lazy_functions : Sequence[Callable[..., None]]
+
+

The type of the None singleton.

+
+
var matchers : Sequence[ListenerMatcher]
+
+

The type of the None singleton.

+
+
var middleware : Sequence[Middleware]
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def matches(self,
*,
req: BoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
def matches(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+) -> bool:
+    is_matched: bool = False
+    for matcher in self.matchers:
+        is_matched = matcher.matches(req, resp)
+        if not is_matched:
+            return is_matched
+    return is_matched
+
+
+
+
+def run_ack_function(self,
*,
request: BoltRequest,
response: BoltResponse) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+def run_ack_function(self, *, request: BoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+    """Runs all the registered middleware and then run the listener function.
+
+    Args:
+        request: The incoming request
+        response: The current response
+
+    Returns:
+        The processed response
+    """
+    raise NotImplementedError()
+
+

Runs all the registered middleware and then run the listener function.

+

Args

+
+
request
+
The incoming request
+
response
+
The current response
+
+

Returns

+

The processed response

+
+
+def run_middleware(self,
*,
req: BoltRequest,
resp: BoltResponse) ‑> Tuple[BoltResponse | None, bool]
+
+
+
+ +Expand source code + +
def run_middleware(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+) -> Tuple[Optional[BoltResponse], bool]:
+    """Runs a middleware.
+
+    Args:
+        req: The incoming request
+        resp: The current response
+
+    Returns:
+        A tuple of the processed response and a flag indicating termination
+    """
+    for m in self.middleware:
+        middleware_state = {"next_called": False}
+
+        def next_():
+            middleware_state["next_called"] = True
+
+        resp = m.process(req=req, resp=resp, next=next_)  # type: ignore[assignment]
+        if not middleware_state["next_called"]:
+            # next() was not called in this middleware
+            return (resp, True)
+    return (resp, False)
+
+

Runs a middleware.

+

Args

+
+
req
+
The incoming request
+
resp
+
The current response
+
+

Returns

+

A tuple of the processed response and a flag indicating termination

+
+
+
+
+class Respond +(*,
response_url: str | None,
proxy: str | None = None,
ssl: ssl.SSLContext | None = None)
+
+
+
+ +Expand source code + +
class Respond:
+    response_url: Optional[str]
+    proxy: Optional[str]
+    ssl: Optional[SSLContext]
+
+    def __init__(
+        self,
+        *,
+        response_url: Optional[str],
+        proxy: Optional[str] = None,
+        ssl: Optional[SSLContext] = None,
+    ):
+        self.response_url = response_url
+        self.proxy = proxy
+        self.ssl = ssl
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[dict, Attachment]]] = None,
+        response_type: Optional[str] = None,
+        replace_original: Optional[bool] = None,
+        delete_original: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Dict[str, Any]] = None,
+    ) -> WebhookResponse:
+        if self.response_url is not None:
+            client = WebhookClient(
+                url=self.response_url,
+                proxy=self.proxy,
+                ssl=self.ssl,
+            )
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                message = _build_message(
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    response_type=response_type,
+                    replace_original=replace_original,
+                    delete_original=delete_original,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    thread_ts=thread_ts,
+                    metadata=metadata,
+                )
+                return client.send_dict(message)
+            elif isinstance(text_or_whole_response, dict):
+                message = _build_message(**text_or_whole_response)
+                return client.send_dict(message)
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("respond is unsupported here as there is no response_url")
+
+
+

Class variables

+
+
var proxy : str | None
+
+

The type of the None singleton.

+
+
var response_url : str | None
+
+

The type of the None singleton.

+
+
var ssl : ssl.SSLContext | None
+
+

The type of the None singleton.

+
+
+
+
+class SaveThreadContext +(thread_context_store: AssistantThreadContextStore,
channel_id: str,
thread_ts: str)
+
+
+
+ +Expand source code + +
class SaveThreadContext:
+    thread_context_store: AssistantThreadContextStore
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        thread_context_store: AssistantThreadContextStore,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.thread_context_store = thread_context_store
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    def __call__(self, new_context: Dict[str, str]) -> None:
+        self.thread_context_store.save(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            context=new_context,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+class Say +(client: slack_sdk.web.client.WebClient | None,
channel: str | None,
thread_ts: str | None = None,
metadata: Dict | slack_sdk.models.metadata.Metadata | None = None,
build_metadata: Callable[[], Dict | slack_sdk.models.metadata.Metadata | None] | None = None)
+
+
+
+ +Expand source code + +
class Say:
+    client: Optional[WebClient]
+    channel: Optional[str]
+    thread_ts: Optional[str]
+    metadata: Optional[Union[Dict, Metadata]]
+    build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]]
+
+    def __init__(
+        self,
+        client: Optional[WebClient],
+        channel: Optional[str],
+        thread_ts: Optional[str] = None,
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]] = None,
+    ):
+        self.client = client
+        self.channel = channel
+        self.thread_ts = thread_ts
+        self.metadata = metadata
+        self.build_metadata = build_metadata
+
+    def __call__(
+        self,
+        text: Union[str, dict] = "",
+        blocks: Optional[Sequence[Union[Dict, Block]]] = None,
+        attachments: Optional[Sequence[Union[Dict, Attachment]]] = None,
+        channel: Optional[str] = None,
+        as_user: Optional[bool] = None,
+        thread_ts: Optional[str] = None,
+        reply_broadcast: Optional[bool] = None,
+        unfurl_links: Optional[bool] = None,
+        unfurl_media: Optional[bool] = None,
+        icon_emoji: Optional[str] = None,
+        icon_url: Optional[str] = None,
+        username: Optional[str] = None,
+        markdown_text: Optional[str] = None,
+        mrkdwn: Optional[bool] = None,
+        link_names: Optional[bool] = None,
+        parse: Optional[str] = None,  # none, full
+        metadata: Optional[Union[Dict, Metadata]] = None,
+        **kwargs,
+    ) -> SlackResponse:
+        if _can_say(self, channel):
+            text_or_whole_response: Union[str, dict] = text
+            if isinstance(text_or_whole_response, str):
+                text = text_or_whole_response
+                if metadata is None:
+                    metadata = self.build_metadata() if self.build_metadata is not None else self.metadata
+                return self.client.chat_postMessage(  # type: ignore[union-attr]
+                    channel=channel or self.channel,  # type: ignore[arg-type]
+                    text=text,
+                    blocks=blocks,
+                    attachments=attachments,
+                    as_user=as_user,
+                    thread_ts=thread_ts or self.thread_ts,
+                    reply_broadcast=reply_broadcast,
+                    unfurl_links=unfurl_links,
+                    unfurl_media=unfurl_media,
+                    icon_emoji=icon_emoji,
+                    icon_url=icon_url,
+                    username=username,
+                    markdown_text=markdown_text,
+                    mrkdwn=mrkdwn,
+                    link_names=link_names,
+                    parse=parse,
+                    metadata=metadata,
+                    **kwargs,
+                )
+            elif isinstance(text_or_whole_response, dict):
+                message: dict = create_copy(text_or_whole_response)
+                if "channel" not in message:
+                    message["channel"] = channel or self.channel
+                if "thread_ts" not in message:
+                    message["thread_ts"] = thread_ts or self.thread_ts
+                if "metadata" not in message:
+                    metadata = self.build_metadata() if self.build_metadata is not None else self.metadata
+                    message["metadata"] = metadata
+                return self.client.chat_postMessage(**message)  # type: ignore[union-attr]
+            else:
+                raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})")
+        else:
+            raise ValueError("say without channel_id here is unsupported")
+
+
+

Class variables

+
+
var build_metadata : Callable[[], Dict | slack_sdk.models.metadata.Metadata | None] | None
+
+

The type of the None singleton.

+
+
var channel : str | None
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient | None
+
+

The type of the None singleton.

+
+
var metadata : Dict | slack_sdk.models.metadata.Metadata | None
+
+

The type of the None singleton.

+
+
var thread_ts : str | None
+
+

The type of the None singleton.

+
+
+
+
+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) +
+
+
+ +Expand source code + +
class SetStatus:
+    client: WebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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(
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+            status=status,
+            loading_messages=loading_messages,
+            icon_emoji=icon_emoji,
+            icon_url=icon_url,
+            username=username,
+            **kwargs,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+class SetSuggestedPrompts +(client: slack_sdk.web.client.WebClient,
channel_id: str,
thread_ts: str | None = None)
+
+
+
+ +Expand source code + +
class SetSuggestedPrompts:
+    client: WebClient
+    channel_id: str
+    thread_ts: Optional[str]
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: Optional[str] = None,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    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:
+            if isinstance(prompt, str):
+                prompts_arg.append({"title": prompt, "message": prompt})
+            else:
+                prompts_arg.append(prompt)
+
+        return self.client.assistant_threads_setSuggestedPrompts(
+            channel_id=self.channel_id,
+            thread_ts=thread_ts if thread_ts is not None else self.thread_ts,
+            prompts=prompts_arg,
+            title=title,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str | None
+
+

The type of the None singleton.

+
+
+
+
+class SetTitle +(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +
+
+
+ +Expand source code + +
class SetTitle:
+    client: WebClient
+    channel_id: str
+    thread_ts: str
+
+    def __init__(
+        self,
+        client: WebClient,
+        channel_id: str,
+        thread_ts: str,
+    ):
+        self.client = client
+        self.channel_id = channel_id
+        self.thread_ts = thread_ts
+
+    def __call__(self, title: str) -> SlackResponse:
+        return self.client.assistant_threads_setTitle(
+            title=title,
+            channel_id=self.channel_id,
+            thread_ts=self.thread_ts,
+        )
+
+
+

Class variables

+
+
var channel_id : str
+
+

The type of the None singleton.

+
+
var client : slack_sdk.web.client.WebClient
+
+

The type of the None singleton.

+
+
var thread_ts : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/kwargs_injection/args.html b/docs/reference/kwargs_injection/args.html new file mode 100644 index 000000000..bbba71eb8 --- /dev/null +++ b/docs/reference/kwargs_injection/args.html @@ -0,0 +1,419 @@ + + + + + + +slack_bolt.kwargs_injection.args API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.kwargs_injection.args

+
+
+
+
+
+
+
+
+
+
+

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,
say_stream: SayStream | None = None,
next: Callable[[], None],
**kwargs)
+
+
+
+ +Expand source code + +
class Args:
+    """All the arguments in this class are available in any middleware / listeners.
+    You can inject the named variables in the argument list in arbitrary order.
+
+        @app.action("link_button")
+        def handle_buttons(ack, respond, logger, context, body, client):
+            logger.info(f"request body: {body}")
+            ack()
+            if context.channel_id is not None:
+                respond("Hi!")
+            client.views_open(
+                trigger_id=body["trigger_id"],
+                view={ ... }
+            )
+
+    Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class.
+
+        @app.action("link_button")
+        def handle_buttons(args):
+            args.logger.info(f"request body: {args.body}")
+            args.ack()
+            if args.context.channel_id is not None:
+                args.respond("Hi!")
+            args.client.views_open(
+                trigger_id=args.body["trigger_id"],
+                view={ ... }
+            )
+
+    """
+
+    client: WebClient
+    """`slack_sdk.web.WebClient` instance with a valid token"""
+    logger: Logger
+    """Logger instance"""
+    req: BoltRequest
+    """Incoming request from Slack"""
+    resp: BoltResponse
+    """Response representation"""
+    request: BoltRequest
+    """Incoming request from Slack"""
+    response: BoltResponse
+    """Response representation"""
+    context: BoltContext
+    """Context data associated with the incoming request"""
+    body: Dict[str, Any]
+    """Parsed request body data"""
+    # payload
+    payload: Dict[str, Any]
+    """The unwrapped core data in the request body"""
+    options: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.options` listener"""
+    shortcut: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.shortcut` listener"""
+    action: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.action` listener"""
+    view: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.view` listener"""
+    command: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.command` listener"""
+    event: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.event` listener"""
+    message: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.message` listener"""
+    # utilities
+    ack: Ack
+    """`ack()` utility function, which returns acknowledgement to the Slack servers"""
+    say: Say
+    """`say()` utility function, which calls `chat.postMessage` API with the associated channel ID"""
+    respond: Respond
+    """`respond()` utility function, which utilizes the associated `response_url`"""
+    complete: Complete
+    """`complete()` utility function, signals a successful completion of the custom function"""
+    fail: Fail
+    """`fail()` utility function, signal that the custom function failed to complete"""
+    set_status: Optional[SetStatus]
+    """`set_status()` utility function for AI Agents & Assistants"""
+    set_title: Optional[SetTitle]
+    """`set_title()` utility function for AI Agents & Assistants"""
+    set_suggested_prompts: Optional[SetSuggestedPrompts]
+    """`set_suggested_prompts()` utility function for AI Agents & Assistants"""
+    get_thread_context: Optional[GetThreadContext]
+    """`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"""
+    next_: Callable[[], None]
+    """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions"""
+
+    def __init__(
+        self,
+        *,
+        logger: logging.Logger,
+        client: WebClient,
+        req: BoltRequest,
+        resp: BoltResponse,
+        context: BoltContext,
+        body: Dict[str, Any],
+        payload: Dict[str, Any],
+        options: Optional[Dict[str, Any]] = None,
+        shortcut: Optional[Dict[str, Any]] = None,
+        action: Optional[Dict[str, Any]] = None,
+        view: Optional[Dict[str, Any]] = None,
+        command: Optional[Dict[str, Any]] = None,
+        event: Optional[Dict[str, Any]] = None,
+        message: Optional[Dict[str, Any]] = None,
+        ack: Ack,
+        say: Say,
+        respond: Respond,
+        complete: Complete,
+        fail: Fail,
+        set_status: Optional[SetStatus] = None,
+        set_title: Optional[SetTitle] = None,
+        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
+        next: Callable[[], None],
+        **kwargs,  # noqa
+    ):
+        self.logger: logging.Logger = logger
+        self.client: WebClient = client
+        self.request = self.req = req
+        self.response = self.resp = resp
+        self.context: BoltContext = context
+
+        self.body: Dict[str, Any] = body
+        self.payload: Dict[str, Any] = payload
+        self.options: Optional[Dict[str, Any]] = options
+        self.shortcut: Optional[Dict[str, Any]] = shortcut
+        self.action: Optional[Dict[str, Any]] = action
+        self.view: Optional[Dict[str, Any]] = view
+        self.command: Optional[Dict[str, Any]] = command
+        self.event: Optional[Dict[str, Any]] = event
+        self.message: Optional[Dict[str, Any]] = message
+
+        self.ack: Ack = ack
+        self.say: Say = say
+        self.respond: Respond = respond
+        self.complete: Complete = complete
+        self.fail: Fail = fail
+
+        self.set_status = set_status
+        self.set_title = set_title
+        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
+
+

All the arguments in this class are available in any middleware / listeners. +You can inject the named variables in the argument list in arbitrary order.

+
@app.action("link_button")
+def handle_buttons(ack, respond, logger, context, body, client):
+    logger.info(f"request body: {body}")
+    ack()
+    if context.channel_id is not None:
+        respond("Hi!")
+    client.views_open(
+        trigger_id=body["trigger_id"],
+        view={ ... }
+    )
+
+

Alternatively, you can include a parameter named args and it will be injected with an instance of this class.

+
@app.action("link_button")
+def handle_buttons(args):
+    args.logger.info(f"request body: {args.body}")
+    args.ack()
+    if args.context.channel_id is not None:
+        args.respond("Hi!")
+    args.client.views_open(
+        trigger_id=args.body["trigger_id"],
+        view={ ... }
+    )
+
+

Class variables

+
+
var ackAck
+
+

ack() utility function, which returns acknowledgement to the Slack servers

+
+
var action : Dict[str, Any] | None
+
+

An alias for payload in an @app.action listener

+
+
var body : Dict[str, Any]
+
+

Parsed request body data

+
+
var client : slack_sdk.web.client.WebClient
+
+

slack_sdk.web.WebClient instance with a valid token

+
+
var command : Dict[str, Any] | None
+
+

An alias for payload in an @app.command listener

+
+
var completeComplete
+
+

complete() utility function, signals a successful completion of the custom function

+
+
var contextBoltContext
+
+

Context data associated with the incoming request

+
+
var event : Dict[str, Any] | None
+
+

An alias for payload in an @app.event listener

+
+
var failFail
+
+

fail() utility function, signal that the custom function failed to complete

+
+
var get_thread_contextGetThreadContext | None
+
+

get_thread_context() utility function for AI Agents & Assistants

+
+
var logger : logging.Logger
+
+

Logger instance

+
+
var message : Dict[str, Any] | None
+
+

An alias for payload in an @app.message listener

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

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

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

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

+
+
var options : Dict[str, Any] | None
+
+

An alias for payload in an @app.options listener

+
+
var payload : Dict[str, Any]
+
+

The unwrapped core data in the request body

+
+
var reqBoltRequest
+
+

Incoming request from Slack

+
+
var requestBoltRequest
+
+

Incoming request from Slack

+
+
var respBoltResponse
+
+

Response representation

+
+
var respondRespond
+
+

respond() utility function, which utilizes the associated response_url

+
+
var responseBoltResponse
+
+

Response representation

+
+
var save_thread_contextSaveThreadContext | None
+
+

save_thread_context() utility function for AI Agents & Assistants

+
+
var saySay
+
+

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

+
+
var set_suggested_promptsSetSuggestedPrompts | None
+
+

set_suggested_prompts() utility function for AI Agents & Assistants

+
+
var set_titleSetTitle | None
+
+

set_title() utility function for AI Agents & Assistants

+
+
var shortcut : Dict[str, Any] | None
+
+

An alias for payload in an @app.shortcut listener

+
+
var view : Dict[str, Any] | None
+
+

An alias for payload in an @app.view listener

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/kwargs_injection/async_args.html b/docs/reference/kwargs_injection/async_args.html new file mode 100644 index 000000000..5b0e7b70e --- /dev/null +++ b/docs/reference/kwargs_injection/async_args.html @@ -0,0 +1,416 @@ + + + + + + +slack_bolt.kwargs_injection.async_args API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.kwargs_injection.async_args

+
+
+
+
+
+
+
+
+
+
+

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,
say_stream: AsyncSayStream | None = None,
next: Callable[[], Awaitable[None]],
**kwargs)
+
+
+
+ +Expand source code + +
class AsyncArgs:
+    """All the arguments in this class are available in any middleware / listeners.
+    You can inject the named variables in the argument list in arbitrary order.
+
+        @app.action("link_button")
+        async def handle_buttons(ack, respond, logger, context, body, client):
+            logger.info(f"request body: {body}")
+            await ack()
+            if context.channel_id is not None:
+                await respond("Hi!")
+            await client.views_open(
+                trigger_id=body["trigger_id"],
+                view={ ... }
+            )
+
+    Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class.
+
+        @app.action("link_button")
+        async def handle_buttons(args):
+            args.logger.info(f"request body: {args.body}")
+            await args.ack()
+            if args.context.channel_id is not None:
+                await args.respond("Hi!")
+            await args.client.views_open(
+                trigger_id=args.body["trigger_id"],
+                view={ ... }
+            )
+
+    """
+
+    logger: Logger
+    """Logger instance"""
+    client: AsyncWebClient
+    """`slack_sdk.web.async_client.AsyncWebClient` instance with a valid token"""
+    req: AsyncBoltRequest
+    """Incoming request from Slack"""
+    resp: BoltResponse
+    """Response representation"""
+    request: AsyncBoltRequest
+    """Incoming request from Slack"""
+    response: BoltResponse
+    """Response representation"""
+    context: AsyncBoltContext
+    """Context data associated with the incoming request"""
+    body: Dict[str, Any]
+    """Parsed request body data"""
+    # payload
+    payload: Dict[str, Any]
+    """The unwrapped core data in the request body"""
+    options: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.options` listener"""
+    shortcut: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.shortcut` listener"""
+    action: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.action` listener"""
+    view: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.view` listener"""
+    command: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.command` listener"""
+    event: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.event` listener"""
+    message: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.message` listener"""
+    # utilities
+    ack: AsyncAck
+    """`ack()` utility function, which returns acknowledgement to the Slack servers"""
+    say: AsyncSay
+    """`say()` utility function, which calls chat.postMessage API with the associated channel ID"""
+    respond: AsyncRespond
+    """`respond()` utility function, which utilizes the associated `response_url`"""
+    complete: AsyncComplete
+    """`complete()` utility function, signals a successful completion of the custom function"""
+    fail: AsyncFail
+    """`fail()` utility function, signal that the custom function failed to complete"""
+    set_status: Optional[AsyncSetStatus]
+    """`set_status()` utility function for AI Agents & Assistants"""
+    set_title: Optional[AsyncSetTitle]
+    """`set_title()` utility function for AI Agents & Assistants"""
+    set_suggested_prompts: Optional[AsyncSetSuggestedPrompts]
+    """`set_suggested_prompts()` utility function for AI Agents & Assistants"""
+    get_thread_context: Optional[AsyncGetThreadContext]
+    """`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"""
+    next_: Callable[[], Awaitable[None]]
+    """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions"""
+
+    def __init__(
+        self,
+        *,
+        logger: Logger,
+        client: AsyncWebClient,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        context: AsyncBoltContext,
+        body: Dict[str, Any],
+        payload: Dict[str, Any],
+        options: Optional[Dict[str, Any]] = None,
+        shortcut: Optional[Dict[str, Any]] = None,
+        action: Optional[Dict[str, Any]] = None,
+        view: Optional[Dict[str, Any]] = None,
+        command: Optional[Dict[str, Any]] = None,
+        event: Optional[Dict[str, Any]] = None,
+        message: Optional[Dict[str, Any]] = None,
+        ack: AsyncAck,
+        say: AsyncSay,
+        respond: AsyncRespond,
+        complete: AsyncComplete,
+        fail: AsyncFail,
+        set_status: Optional[AsyncSetStatus] = None,
+        set_title: Optional[AsyncSetTitle] = None,
+        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
+    ):
+        self.logger: Logger = logger
+        self.client: AsyncWebClient = client
+        self.request = self.req = req
+        self.response = self.resp = resp
+        self.context: AsyncBoltContext = context
+
+        self.body: Dict[str, Any] = body
+        self.payload: Dict[str, Any] = payload
+        self.options: Optional[Dict[str, Any]] = options
+        self.shortcut: Optional[Dict[str, Any]] = shortcut
+        self.action: Optional[Dict[str, Any]] = action
+        self.view: Optional[Dict[str, Any]] = view
+        self.command: Optional[Dict[str, Any]] = command
+        self.event: Optional[Dict[str, Any]] = event
+        self.message: Optional[Dict[str, Any]] = message
+
+        self.ack: AsyncAck = ack
+        self.say: AsyncSay = say
+        self.respond: AsyncRespond = respond
+        self.complete: AsyncComplete = complete
+        self.fail: AsyncFail = fail
+
+        self.set_status = set_status
+        self.set_title = set_title
+        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
+
+

All the arguments in this class are available in any middleware / listeners. +You can inject the named variables in the argument list in arbitrary order.

+
@app.action("link_button")
+async def handle_buttons(ack, respond, logger, context, body, client):
+    logger.info(f"request body: {body}")
+    await ack()
+    if context.channel_id is not None:
+        await respond("Hi!")
+    await client.views_open(
+        trigger_id=body["trigger_id"],
+        view={ ... }
+    )
+
+

Alternatively, you can include a parameter named args and it will be injected with an instance of this class.

+
@app.action("link_button")
+async def handle_buttons(args):
+    args.logger.info(f"request body: {args.body}")
+    await args.ack()
+    if args.context.channel_id is not None:
+        await args.respond("Hi!")
+    await args.client.views_open(
+        trigger_id=args.body["trigger_id"],
+        view={ ... }
+    )
+
+

Class variables

+
+
var ackAsyncAck
+
+

ack() utility function, which returns acknowledgement to the Slack servers

+
+
var action : Dict[str, Any] | None
+
+

An alias for payload in an @app.action listener

+
+
var body : Dict[str, Any]
+
+

Parsed request body data

+
+
var client : slack_sdk.web.async_client.AsyncWebClient
+
+

slack_sdk.web.async_client.AsyncWebClient instance with a valid token

+
+
var command : Dict[str, Any] | None
+
+

An alias for payload in an @app.command listener

+
+
var completeAsyncComplete
+
+

complete() utility function, signals a successful completion of the custom function

+
+
var contextAsyncBoltContext
+
+

Context data associated with the incoming request

+
+
var event : Dict[str, Any] | None
+
+

An alias for payload in an @app.event listener

+
+
var failAsyncFail
+
+

fail() utility function, signal that the custom function failed to complete

+
+
var get_thread_contextAsyncGetThreadContext | None
+
+

get_thread_context() utility function for AI Agents & Assistants

+
+
var logger : logging.Logger
+
+

Logger instance

+
+
var message : Dict[str, Any] | None
+
+

An alias for payload in an @app.message listener

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

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

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

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

+
+
var options : Dict[str, Any] | None
+
+

An alias for payload in an @app.options listener

+
+
var payload : Dict[str, Any]
+
+

The unwrapped core data in the request body

+
+
var reqAsyncBoltRequest
+
+

Incoming request from Slack

+
+
var requestAsyncBoltRequest
+
+

Incoming request from Slack

+
+
var respBoltResponse
+
+

Response representation

+
+
var respondAsyncRespond
+
+

respond() utility function, which utilizes the associated response_url

+
+
var responseBoltResponse
+
+

Response representation

+
+
var save_thread_contextAsyncSaveThreadContext | None
+
+

save_thread_context() utility function for AI Agents & Assistants

+
+
var sayAsyncSay
+
+

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

+
+
var set_suggested_promptsAsyncSetSuggestedPrompts | None
+
+

set_suggested_prompts() utility function for AI Agents & Assistants

+
+
var set_titleAsyncSetTitle | None
+
+

set_title() utility function for AI Agents & Assistants

+
+
var shortcut : Dict[str, Any] | None
+
+

An alias for payload in an @app.shortcut listener

+
+
var view : Dict[str, Any] | None
+
+

An alias for payload in an @app.view listener

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/kwargs_injection/async_utils.html b/docs/reference/kwargs_injection/async_utils.html new file mode 100644 index 000000000..7af3a7679 --- /dev/null +++ b/docs/reference/kwargs_injection/async_utils.html @@ -0,0 +1,178 @@ + + + + + + +slack_bolt.kwargs_injection.async_utils API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.kwargs_injection.async_utils

+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_async_required_kwargs(*,
logger: logging.Logger,
required_arg_names: MutableSequence[str],
request: AsyncBoltRequest,
response: BoltResponse | None,
next_func: Callable[[], None] | None = None,
this_func: Callable | None = None,
error: Exception | None = None,
next_keys_required: bool = True) ‑> Dict[str, Any]
+
+
+
+ +Expand source code + +
def build_async_required_kwargs(
+    *,
+    logger: logging.Logger,
+    required_arg_names: MutableSequence[str],
+    request: AsyncBoltRequest,
+    response: Optional[BoltResponse],
+    next_func: Optional[Callable[[], None]] = None,
+    this_func: Optional[Callable] = None,
+    error: Optional[Exception] = None,  # for error handlers
+    next_keys_required: bool = True,  # False for listeners / middleware / error handlers
+) -> Dict[str, Any]:
+    all_available_args: Dict[str, Any] = {
+        "logger": logger,
+        "client": request.context.client,
+        "req": request,
+        "request": request,
+        "resp": response,
+        "response": response,
+        "context": request.context,
+        "body": request.body,
+        # payload
+        "options": to_options(request.body),
+        "shortcut": to_shortcut(request.body),
+        "action": to_action(request.body),
+        "view": to_view(request.body),
+        "command": to_command(request.body),
+        "event": to_event(request.body),
+        "message": to_message(request.body),
+        "step": to_step(request.body),
+        # utilities
+        "ack": request.context.ack,
+        "say": request.context.say,
+        "respond": request.context.respond,
+        "complete": request.context.complete,
+        "fail": request.context.fail,
+        "set_status": request.context.set_status,
+        "set_title": request.context.set_title,
+        "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
+        # error handler
+        "error": error,  # Exception
+    }
+    if not next_keys_required:
+        all_available_args.pop("next")
+        all_available_args.pop("next_")
+
+    all_available_args["payload"] = (
+        all_available_args["options"]
+        or all_available_args["shortcut"]
+        or all_available_args["action"]
+        or all_available_args["view"]
+        or all_available_args["command"]
+        or all_available_args["event"]
+        or all_available_args["message"]
+        or all_available_args["step"]
+        or request.body
+    )
+    for k, v in request.context.items():
+        if k not in all_available_args:
+            all_available_args[k] = v
+
+    if len(required_arg_names) > 0:
+        # To support instance/class methods in a class for listeners/middleware,
+        # check if the first argument is either self or cls
+        first_arg_name = required_arg_names[0]
+        if first_arg_name in {"self", "cls"}:
+            required_arg_names.pop(0)
+        elif first_arg_name not in all_available_args.keys() and first_arg_name != "args":
+            if this_func is None:
+                logger.warning(warning_skip_uncommon_arg_name(first_arg_name))
+                required_arg_names.pop(0)
+            elif inspect.ismethod(this_func):
+                # We are sure that we should skip manipulating this arg
+                required_arg_names.pop(0)
+
+    kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in required_arg_names}
+    found_arg_names = kwargs.keys()
+    for name in required_arg_names:
+        if name == "args":
+            if isinstance(request, AsyncBoltRequest):
+                kwargs[name] = AsyncArgs(**all_available_args)
+            else:
+                logger.warning(f"Unknown Request object type detected ({type(request)})")
+
+        elif name not in found_arg_names:
+            logger.warning(f"{name} is not a valid argument")
+            kwargs[name] = None
+    return kwargs
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/kwargs_injection/index.html b/docs/reference/kwargs_injection/index.html new file mode 100644 index 000000000..cb17cea5d --- /dev/null +++ b/docs/reference/kwargs_injection/index.html @@ -0,0 +1,560 @@ + + + + + + +slack_bolt.kwargs_injection API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.kwargs_injection

+
+
+

For middleware/listener arguments, Bolt does flexible data injection in accordance with their names.

+

To learn the available arguments, check slack_bolt.kwargs_injection.args's API document. +For steps from apps, checking slack_bolt.workflows.step.utilities as well should be helpful.

+
+
+

Sub-modules

+
+
slack_bolt.kwargs_injection.args
+
+
+
+
slack_bolt.kwargs_injection.async_args
+
+
+
+
slack_bolt.kwargs_injection.async_utils
+
+
+
+
slack_bolt.kwargs_injection.utils
+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_required_kwargs(*,
logger: logging.Logger,
required_arg_names: MutableSequence[str],
request: BoltRequest,
response: BoltResponse | None,
next_func: Callable[[], None] | None = None,
this_func: Callable | None = None,
error: Exception | None = None,
next_keys_required: bool = True) ‑> Dict[str, Any]
+
+
+
+ +Expand source code + +
def build_required_kwargs(
+    *,
+    logger: logging.Logger,
+    required_arg_names: MutableSequence[str],
+    request: BoltRequest,
+    response: Optional[BoltResponse],
+    next_func: Optional[Callable[[], None]] = None,
+    this_func: Optional[Callable] = None,
+    error: Optional[Exception] = None,  # for error handlers
+    next_keys_required: bool = True,  # False for listeners / middleware / error handlers
+) -> Dict[str, Any]:
+    all_available_args: Dict[str, Any] = {
+        "logger": logger,
+        "client": request.context.client,
+        "req": request,
+        "request": request,
+        "resp": response,
+        "response": response,
+        "context": request.context,
+        # payload
+        "body": request.body,
+        "options": to_options(request.body),
+        "shortcut": to_shortcut(request.body),
+        "action": to_action(request.body),
+        "view": to_view(request.body),
+        "command": to_command(request.body),
+        "event": to_event(request.body),
+        "message": to_message(request.body),
+        "step": to_step(request.body),
+        # utilities
+        "ack": request.context.ack,
+        "say": request.context.say,
+        "respond": request.context.respond,
+        "complete": request.context.complete,
+        "fail": request.context.fail,
+        "set_status": request.context.set_status,
+        "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
+        # error handler
+        "error": error,  # Exception
+    }
+    if not next_keys_required:
+        all_available_args.pop("next")
+        all_available_args.pop("next_")
+
+    all_available_args["payload"] = (
+        all_available_args["options"]
+        or all_available_args["shortcut"]
+        or all_available_args["action"]
+        or all_available_args["view"]
+        or all_available_args["command"]
+        or all_available_args["event"]
+        or all_available_args["message"]
+        or all_available_args["step"]
+        or request.body
+    )
+    for k, v in request.context.items():
+        if k not in all_available_args:
+            all_available_args[k] = v
+
+    if len(required_arg_names) > 0:
+        # To support instance/class methods in a class for listeners/middleware,
+        # check if the first argument is either self or cls
+        first_arg_name = required_arg_names[0]
+        if first_arg_name in {"self", "cls"}:
+            required_arg_names.pop(0)
+        elif first_arg_name not in all_available_args.keys() and first_arg_name != "args":
+            if this_func is None:
+                logger.warning(warning_skip_uncommon_arg_name(first_arg_name))
+                required_arg_names.pop(0)
+            elif inspect.ismethod(this_func):
+                # We are sure that we should skip manipulating this arg
+                required_arg_names.pop(0)
+
+    kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in required_arg_names}
+    found_arg_names = kwargs.keys()
+    for name in required_arg_names:
+        if name == "args":
+            if isinstance(request, BoltRequest):
+                kwargs[name] = Args(**all_available_args)
+            else:
+                logger.warning(f"Unknown Request object type detected ({type(request)})")
+
+        elif name not in found_arg_names:
+            logger.warning(f"{name} is not a valid argument")
+            kwargs[name] = None
+    return kwargs
+
+
+
+
+
+
+

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,
say_stream: SayStream | None = None,
next: Callable[[], None],
**kwargs)
+
+
+
+ +Expand source code + +
class Args:
+    """All the arguments in this class are available in any middleware / listeners.
+    You can inject the named variables in the argument list in arbitrary order.
+
+        @app.action("link_button")
+        def handle_buttons(ack, respond, logger, context, body, client):
+            logger.info(f"request body: {body}")
+            ack()
+            if context.channel_id is not None:
+                respond("Hi!")
+            client.views_open(
+                trigger_id=body["trigger_id"],
+                view={ ... }
+            )
+
+    Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class.
+
+        @app.action("link_button")
+        def handle_buttons(args):
+            args.logger.info(f"request body: {args.body}")
+            args.ack()
+            if args.context.channel_id is not None:
+                args.respond("Hi!")
+            args.client.views_open(
+                trigger_id=args.body["trigger_id"],
+                view={ ... }
+            )
+
+    """
+
+    client: WebClient
+    """`slack_sdk.web.WebClient` instance with a valid token"""
+    logger: Logger
+    """Logger instance"""
+    req: BoltRequest
+    """Incoming request from Slack"""
+    resp: BoltResponse
+    """Response representation"""
+    request: BoltRequest
+    """Incoming request from Slack"""
+    response: BoltResponse
+    """Response representation"""
+    context: BoltContext
+    """Context data associated with the incoming request"""
+    body: Dict[str, Any]
+    """Parsed request body data"""
+    # payload
+    payload: Dict[str, Any]
+    """The unwrapped core data in the request body"""
+    options: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.options` listener"""
+    shortcut: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.shortcut` listener"""
+    action: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.action` listener"""
+    view: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.view` listener"""
+    command: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.command` listener"""
+    event: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.event` listener"""
+    message: Optional[Dict[str, Any]]  # payload alias
+    """An alias for payload in an `@app.message` listener"""
+    # utilities
+    ack: Ack
+    """`ack()` utility function, which returns acknowledgement to the Slack servers"""
+    say: Say
+    """`say()` utility function, which calls `chat.postMessage` API with the associated channel ID"""
+    respond: Respond
+    """`respond()` utility function, which utilizes the associated `response_url`"""
+    complete: Complete
+    """`complete()` utility function, signals a successful completion of the custom function"""
+    fail: Fail
+    """`fail()` utility function, signal that the custom function failed to complete"""
+    set_status: Optional[SetStatus]
+    """`set_status()` utility function for AI Agents & Assistants"""
+    set_title: Optional[SetTitle]
+    """`set_title()` utility function for AI Agents & Assistants"""
+    set_suggested_prompts: Optional[SetSuggestedPrompts]
+    """`set_suggested_prompts()` utility function for AI Agents & Assistants"""
+    get_thread_context: Optional[GetThreadContext]
+    """`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"""
+    next_: Callable[[], None]
+    """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions"""
+
+    def __init__(
+        self,
+        *,
+        logger: logging.Logger,
+        client: WebClient,
+        req: BoltRequest,
+        resp: BoltResponse,
+        context: BoltContext,
+        body: Dict[str, Any],
+        payload: Dict[str, Any],
+        options: Optional[Dict[str, Any]] = None,
+        shortcut: Optional[Dict[str, Any]] = None,
+        action: Optional[Dict[str, Any]] = None,
+        view: Optional[Dict[str, Any]] = None,
+        command: Optional[Dict[str, Any]] = None,
+        event: Optional[Dict[str, Any]] = None,
+        message: Optional[Dict[str, Any]] = None,
+        ack: Ack,
+        say: Say,
+        respond: Respond,
+        complete: Complete,
+        fail: Fail,
+        set_status: Optional[SetStatus] = None,
+        set_title: Optional[SetTitle] = None,
+        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
+        next: Callable[[], None],
+        **kwargs,  # noqa
+    ):
+        self.logger: logging.Logger = logger
+        self.client: WebClient = client
+        self.request = self.req = req
+        self.response = self.resp = resp
+        self.context: BoltContext = context
+
+        self.body: Dict[str, Any] = body
+        self.payload: Dict[str, Any] = payload
+        self.options: Optional[Dict[str, Any]] = options
+        self.shortcut: Optional[Dict[str, Any]] = shortcut
+        self.action: Optional[Dict[str, Any]] = action
+        self.view: Optional[Dict[str, Any]] = view
+        self.command: Optional[Dict[str, Any]] = command
+        self.event: Optional[Dict[str, Any]] = event
+        self.message: Optional[Dict[str, Any]] = message
+
+        self.ack: Ack = ack
+        self.say: Say = say
+        self.respond: Respond = respond
+        self.complete: Complete = complete
+        self.fail: Fail = fail
+
+        self.set_status = set_status
+        self.set_title = set_title
+        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
+
+

All the arguments in this class are available in any middleware / listeners. +You can inject the named variables in the argument list in arbitrary order.

+
@app.action("link_button")
+def handle_buttons(ack, respond, logger, context, body, client):
+    logger.info(f"request body: {body}")
+    ack()
+    if context.channel_id is not None:
+        respond("Hi!")
+    client.views_open(
+        trigger_id=body["trigger_id"],
+        view={ ... }
+    )
+
+

Alternatively, you can include a parameter named slack_bolt.kwargs_injection.args and it will be injected with an instance of this class.

+
@app.action("link_button")
+def handle_buttons(args):
+    args.logger.info(f"request body: {args.body}")
+    args.ack()
+    if args.context.channel_id is not None:
+        args.respond("Hi!")
+    args.client.views_open(
+        trigger_id=args.body["trigger_id"],
+        view={ ... }
+    )
+
+

Class variables

+
+
var ackAck
+
+

ack() utility function, which returns acknowledgement to the Slack servers

+
+
var action : Dict[str, Any] | None
+
+

An alias for payload in an @app.action listener

+
+
var body : Dict[str, Any]
+
+

Parsed request body data

+
+
var client : slack_sdk.web.client.WebClient
+
+

slack_sdk.web.WebClient instance with a valid token

+
+
var command : Dict[str, Any] | None
+
+

An alias for payload in an @app.command listener

+
+
var completeComplete
+
+

complete() utility function, signals a successful completion of the custom function

+
+
var contextBoltContext
+
+

Context data associated with the incoming request

+
+
var event : Dict[str, Any] | None
+
+

An alias for payload in an @app.event listener

+
+
var failFail
+
+

fail() utility function, signal that the custom function failed to complete

+
+
var get_thread_contextGetThreadContext | None
+
+

get_thread_context() utility function for AI Agents & Assistants

+
+
var logger : logging.Logger
+
+

Logger instance

+
+
var message : Dict[str, Any] | None
+
+

An alias for payload in an @app.message listener

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

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

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

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

+
+
var options : Dict[str, Any] | None
+
+

An alias for payload in an @app.options listener

+
+
var payload : Dict[str, Any]
+
+

The unwrapped core data in the request body

+
+
var reqBoltRequest
+
+

Incoming request from Slack

+
+
var requestBoltRequest
+
+

Incoming request from Slack

+
+
var respBoltResponse
+
+

Response representation

+
+
var respondRespond
+
+

respond() utility function, which utilizes the associated response_url

+
+
var responseBoltResponse
+
+

Response representation

+
+
var save_thread_contextSaveThreadContext | None
+
+

save_thread_context() utility function for AI Agents & Assistants

+
+
var saySay
+
+

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

+
+
var set_suggested_promptsSetSuggestedPrompts | None
+
+

set_suggested_prompts() utility function for AI Agents & Assistants

+
+
var set_titleSetTitle | None
+
+

set_title() utility function for AI Agents & Assistants

+
+
var shortcut : Dict[str, Any] | None
+
+

An alias for payload in an @app.shortcut listener

+
+
var view : Dict[str, Any] | None
+
+

An alias for payload in an @app.view listener

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/kwargs_injection/utils.html b/docs/reference/kwargs_injection/utils.html new file mode 100644 index 000000000..0289fd410 --- /dev/null +++ b/docs/reference/kwargs_injection/utils.html @@ -0,0 +1,177 @@ + + + + + + +slack_bolt.kwargs_injection.utils API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.kwargs_injection.utils

+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_required_kwargs(*,
logger: logging.Logger,
required_arg_names: MutableSequence[str],
request: BoltRequest,
response: BoltResponse | None,
next_func: Callable[[], None] | None = None,
this_func: Callable | None = None,
error: Exception | None = None,
next_keys_required: bool = True) ‑> Dict[str, Any]
+
+
+
+ +Expand source code + +
def build_required_kwargs(
+    *,
+    logger: logging.Logger,
+    required_arg_names: MutableSequence[str],
+    request: BoltRequest,
+    response: Optional[BoltResponse],
+    next_func: Optional[Callable[[], None]] = None,
+    this_func: Optional[Callable] = None,
+    error: Optional[Exception] = None,  # for error handlers
+    next_keys_required: bool = True,  # False for listeners / middleware / error handlers
+) -> Dict[str, Any]:
+    all_available_args: Dict[str, Any] = {
+        "logger": logger,
+        "client": request.context.client,
+        "req": request,
+        "request": request,
+        "resp": response,
+        "response": response,
+        "context": request.context,
+        # payload
+        "body": request.body,
+        "options": to_options(request.body),
+        "shortcut": to_shortcut(request.body),
+        "action": to_action(request.body),
+        "view": to_view(request.body),
+        "command": to_command(request.body),
+        "event": to_event(request.body),
+        "message": to_message(request.body),
+        "step": to_step(request.body),
+        # utilities
+        "ack": request.context.ack,
+        "say": request.context.say,
+        "respond": request.context.respond,
+        "complete": request.context.complete,
+        "fail": request.context.fail,
+        "set_status": request.context.set_status,
+        "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
+        # error handler
+        "error": error,  # Exception
+    }
+    if not next_keys_required:
+        all_available_args.pop("next")
+        all_available_args.pop("next_")
+
+    all_available_args["payload"] = (
+        all_available_args["options"]
+        or all_available_args["shortcut"]
+        or all_available_args["action"]
+        or all_available_args["view"]
+        or all_available_args["command"]
+        or all_available_args["event"]
+        or all_available_args["message"]
+        or all_available_args["step"]
+        or request.body
+    )
+    for k, v in request.context.items():
+        if k not in all_available_args:
+            all_available_args[k] = v
+
+    if len(required_arg_names) > 0:
+        # To support instance/class methods in a class for listeners/middleware,
+        # check if the first argument is either self or cls
+        first_arg_name = required_arg_names[0]
+        if first_arg_name in {"self", "cls"}:
+            required_arg_names.pop(0)
+        elif first_arg_name not in all_available_args.keys() and first_arg_name != "args":
+            if this_func is None:
+                logger.warning(warning_skip_uncommon_arg_name(first_arg_name))
+                required_arg_names.pop(0)
+            elif inspect.ismethod(this_func):
+                # We are sure that we should skip manipulating this arg
+                required_arg_names.pop(0)
+
+    kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in required_arg_names}
+    found_arg_names = kwargs.keys()
+    for name in required_arg_names:
+        if name == "args":
+            if isinstance(request, BoltRequest):
+                kwargs[name] = Args(**all_available_args)
+            else:
+                logger.warning(f"Unknown Request object type detected ({type(request)})")
+
+        elif name not in found_arg_names:
+            logger.warning(f"{name} is not a valid argument")
+            kwargs[name] = None
+    return kwargs
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/lazy_listener/async_internals.html b/docs/reference/lazy_listener/async_internals.html new file mode 100644 index 000000000..9d86a02e5 --- /dev/null +++ b/docs/reference/lazy_listener/async_internals.html @@ -0,0 +1,108 @@ + + + + + + +slack_bolt.lazy_listener.async_internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.lazy_listener.async_internals

+
+
+
+
+
+
+
+
+

Functions

+
+
+async def to_runnable_function(internal_func: Callable[..., Awaitable[None]],
logger: logging.Logger,
request: AsyncBoltRequest)
+
+
+
+ +Expand source code + +
async def to_runnable_function(
+    internal_func: Callable[..., Awaitable[None]],
+    logger: Logger,
+    request: AsyncBoltRequest,
+):
+    arg_names = get_arg_names_of_callable(internal_func)
+
+    @wraps(internal_func)
+    async def request_wired_wrapper() -> None:
+        try:
+            await internal_func(
+                **build_async_required_kwargs(
+                    logger=logger,
+                    required_arg_names=arg_names,
+                    request=request,
+                    response=None,
+                    this_func=internal_func,
+                )
+            )
+        except Exception as e:
+            logger.error(f"Failed to run an internal function ({e})")
+
+    return await request_wired_wrapper()
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/lazy_listener/async_runner.html b/docs/reference/lazy_listener/async_runner.html new file mode 100644 index 000000000..701f1640a --- /dev/null +++ b/docs/reference/lazy_listener/async_runner.html @@ -0,0 +1,190 @@ + + + + + + +slack_bolt.lazy_listener.async_runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.lazy_listener.async_runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncLazyListenerRunner +
+
+
+ +Expand source code + +
class AsyncLazyListenerRunner(metaclass=ABCMeta):
+    logger: Logger
+
+    @abstractmethod
+    def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None:
+        """Starts a new lazy listener execution.
+
+        Args:
+            function: The function to run.
+            request: The request to pass to the function. The object must be thread-safe.
+        """
+        raise NotImplementedError()
+
+    async def run(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None:
+        """Synchronously run the function with a given request data.
+
+        Args:
+            function: The function to run.
+            request: The request to pass to the function. The object must be thread-safe.
+        """
+        func = to_runnable_function(
+            internal_func=function,
+            logger=self.logger,
+            request=request,
+        )
+        return await func()  # type: ignore[operator]
+
+
+

Subclasses

+ +

Class variables

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def run(self,
function: Callable[..., Awaitable[None]],
request: AsyncBoltRequest) ‑> None
+
+
+
+ +Expand source code + +
async def run(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None:
+    """Synchronously run the function with a given request data.
+
+    Args:
+        function: The function to run.
+        request: The request to pass to the function. The object must be thread-safe.
+    """
+    func = to_runnable_function(
+        internal_func=function,
+        logger=self.logger,
+        request=request,
+    )
+    return await func()  # type: ignore[operator]
+
+

Synchronously run the function with a given request data.

+

Args

+
+
function
+
The function to run.
+
request
+
The request to pass to the function. The object must be thread-safe.
+
+
+
+def start(self,
function: Callable[..., Awaitable[None]],
request: AsyncBoltRequest) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None:
+    """Starts a new lazy listener execution.
+
+    Args:
+        function: The function to run.
+        request: The request to pass to the function. The object must be thread-safe.
+    """
+    raise NotImplementedError()
+
+

Starts a new lazy listener execution.

+

Args

+
+
function
+
The function to run.
+
request
+
The request to pass to the function. The object must be thread-safe.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/lazy_listener/asyncio_runner.html b/docs/reference/lazy_listener/asyncio_runner.html new file mode 100644 index 000000000..2fdcf8ffe --- /dev/null +++ b/docs/reference/lazy_listener/asyncio_runner.html @@ -0,0 +1,119 @@ + + + + + + +slack_bolt.lazy_listener.asyncio_runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.lazy_listener.asyncio_runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncioLazyListenerRunner +(logger: logging.Logger) +
+
+
+ +Expand source code + +
class AsyncioLazyListenerRunner(AsyncLazyListenerRunner):
+    logger: Logger
+
+    def __init__(
+        self,
+        logger: Logger,
+    ):
+        self.logger = logger
+
+    def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None:
+        asyncio.ensure_future(
+            to_runnable_function(
+                internal_func=function,
+                logger=self.logger,
+                request=request,
+            )
+        )
+
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/lazy_listener/index.html b/docs/reference/lazy_listener/index.html new file mode 100644 index 000000000..6bc17015e --- /dev/null +++ b/docs/reference/lazy_listener/index.html @@ -0,0 +1,301 @@ + + + + + + +slack_bolt.lazy_listener API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.lazy_listener

+
+
+

Lazy listener runner is a beta feature for the apps running on Function-as-a-Service platforms.

+
def respond_to_slack_within_3_seconds(body, ack):
+    text = body.get("text")
+    if text is None or len(text) == 0:
+        ack(f":x: Usage: /start-process (description here)")
+    else:
+        ack(f"Accepted! (task: {body['text']})")
+
+import time
+def run_long_process(respond, body):
+    time.sleep(5)  # longer than 3 seconds
+    respond(f"Completed! (task: {body['text']})")
+
+app.command("/start-process")(
+    # ack() is still called within 3 seconds
+    ack=respond_to_slack_within_3_seconds,
+    # Lazy function is responsible for processing the event
+    lazy=[run_long_process]
+)
+
+

Refer to https://docs.slack.dev/tools/bolt-python/concepts/lazy-listeners for more details.

+
+
+

Sub-modules

+
+
slack_bolt.lazy_listener.async_internals
+
+
+
+
slack_bolt.lazy_listener.async_runner
+
+
+
+
slack_bolt.lazy_listener.asyncio_runner
+
+
+
+
slack_bolt.lazy_listener.internals
+
+
+
+
slack_bolt.lazy_listener.runner
+
+
+
+
slack_bolt.lazy_listener.thread_runner
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class LazyListenerRunner +
+
+
+ +Expand source code + +
class LazyListenerRunner(metaclass=ABCMeta):
+    logger: Logger
+
+    @abstractmethod
+    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+        """Starts a new lazy listener execution.
+
+        Args:
+            function: The function to run.
+            request: The request to pass to the function. The object must be thread-safe.
+        """
+        raise NotImplementedError()
+
+    def run(self, function: Callable[..., None], request: BoltRequest) -> None:
+        """Synchronously runs the function with a given request data.
+
+        Args:
+            function: The function to run.
+            request: The request to pass to the function. The object must be thread-safe.
+        """
+        build_runnable_function(
+            func=function,
+            logger=self.logger,
+            request=request,
+        )()
+
+
+

Subclasses

+ +

Class variables

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def run(self,
function: Callable[..., None],
request: BoltRequest) ‑> None
+
+
+
+ +Expand source code + +
def run(self, function: Callable[..., None], request: BoltRequest) -> None:
+    """Synchronously runs the function with a given request data.
+
+    Args:
+        function: The function to run.
+        request: The request to pass to the function. The object must be thread-safe.
+    """
+    build_runnable_function(
+        func=function,
+        logger=self.logger,
+        request=request,
+    )()
+
+

Synchronously runs the function with a given request data.

+

Args

+
+
function
+
The function to run.
+
request
+
The request to pass to the function. The object must be thread-safe.
+
+
+
+def start(self,
function: Callable[..., None],
request: BoltRequest) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+    """Starts a new lazy listener execution.
+
+    Args:
+        function: The function to run.
+        request: The request to pass to the function. The object must be thread-safe.
+    """
+    raise NotImplementedError()
+
+

Starts a new lazy listener execution.

+

Args

+
+
function
+
The function to run.
+
request
+
The request to pass to the function. The object must be thread-safe.
+
+
+
+
+
+class ThreadLazyListenerRunner +(logger: logging.Logger, executor: concurrent.futures._base.Executor) +
+
+
+ +Expand source code + +
class ThreadLazyListenerRunner(LazyListenerRunner):
+    logger: Logger
+
+    def __init__(
+        self,
+        logger: Logger,
+        executor: Executor,
+    ):
+        self.logger = logger
+        self.executor = executor
+
+    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+        self.executor.submit(
+            build_runnable_function(
+                func=function,
+                logger=self.logger,
+                request=request,
+            )
+        )
+
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/lazy_listener/internals.html b/docs/reference/lazy_listener/internals.html new file mode 100644 index 000000000..1801abafd --- /dev/null +++ b/docs/reference/lazy_listener/internals.html @@ -0,0 +1,108 @@ + + + + + + +slack_bolt.lazy_listener.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.lazy_listener.internals

+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_runnable_function(func: Callable[..., None],
logger: logging.Logger,
request: BoltRequest) ‑> Callable[[], None]
+
+
+
+ +Expand source code + +
def build_runnable_function(
+    func: Callable[..., None],
+    logger: Logger,
+    request: BoltRequest,
+) -> Callable[[], None]:
+    arg_names = get_arg_names_of_callable(func)
+
+    @wraps(func)
+    def request_wired_func_wrapper() -> None:
+        try:
+            func(
+                **build_required_kwargs(
+                    logger=logger,
+                    required_arg_names=arg_names,
+                    request=request,
+                    response=None,
+                    this_func=func,
+                )
+            )
+        except Exception as e:
+            logger.error(f"Failed to run an internal function ({e})")
+
+    return request_wired_func_wrapper
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/lazy_listener/runner.html b/docs/reference/lazy_listener/runner.html new file mode 100644 index 000000000..ff4f449a0 --- /dev/null +++ b/docs/reference/lazy_listener/runner.html @@ -0,0 +1,191 @@ + + + + + + +slack_bolt.lazy_listener.runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.lazy_listener.runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class LazyListenerRunner +
+
+
+ +Expand source code + +
class LazyListenerRunner(metaclass=ABCMeta):
+    logger: Logger
+
+    @abstractmethod
+    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+        """Starts a new lazy listener execution.
+
+        Args:
+            function: The function to run.
+            request: The request to pass to the function. The object must be thread-safe.
+        """
+        raise NotImplementedError()
+
+    def run(self, function: Callable[..., None], request: BoltRequest) -> None:
+        """Synchronously runs the function with a given request data.
+
+        Args:
+            function: The function to run.
+            request: The request to pass to the function. The object must be thread-safe.
+        """
+        build_runnable_function(
+            func=function,
+            logger=self.logger,
+            request=request,
+        )()
+
+
+

Subclasses

+ +

Class variables

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def run(self,
function: Callable[..., None],
request: BoltRequest) ‑> None
+
+
+
+ +Expand source code + +
def run(self, function: Callable[..., None], request: BoltRequest) -> None:
+    """Synchronously runs the function with a given request data.
+
+    Args:
+        function: The function to run.
+        request: The request to pass to the function. The object must be thread-safe.
+    """
+    build_runnable_function(
+        func=function,
+        logger=self.logger,
+        request=request,
+    )()
+
+

Synchronously runs the function with a given request data.

+

Args

+
+
function
+
The function to run.
+
request
+
The request to pass to the function. The object must be thread-safe.
+
+
+
+def start(self,
function: Callable[..., None],
request: BoltRequest) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+    """Starts a new lazy listener execution.
+
+    Args:
+        function: The function to run.
+        request: The request to pass to the function. The object must be thread-safe.
+    """
+    raise NotImplementedError()
+
+

Starts a new lazy listener execution.

+

Args

+
+
function
+
The function to run.
+
request
+
The request to pass to the function. The object must be thread-safe.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/lazy_listener/thread_runner.html b/docs/reference/lazy_listener/thread_runner.html new file mode 100644 index 000000000..b4ca0711a --- /dev/null +++ b/docs/reference/lazy_listener/thread_runner.html @@ -0,0 +1,125 @@ + + + + + + +slack_bolt.lazy_listener.thread_runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.lazy_listener.thread_runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class ThreadLazyListenerRunner +(logger: logging.Logger, executor: concurrent.futures._base.Executor) +
+
+
+ +Expand source code + +
class ThreadLazyListenerRunner(LazyListenerRunner):
+    logger: Logger
+
+    def __init__(
+        self,
+        logger: Logger,
+        executor: Executor,
+    ):
+        self.logger = logger
+        self.executor = executor
+
+    def start(self, function: Callable[..., None], request: BoltRequest) -> None:
+        self.executor.submit(
+            build_runnable_function(
+                func=function,
+                logger=self.logger,
+                request=request,
+            )
+        )
+
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/async_builtins.html b/docs/reference/listener/async_builtins.html new file mode 100644 index 000000000..015dd94b3 --- /dev/null +++ b/docs/reference/listener/async_builtins.html @@ -0,0 +1,174 @@ + + + + + + +slack_bolt.listener.async_builtins API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.async_builtins

+
+
+
+
+
+
+
+
+
+
+

Classes

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

Listener functions to handle token revocation / uninstallation events

+

Class variables

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

The type of the None singleton.

+
+
+

Methods

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

Module slack_bolt.listener.async_listener

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncCustomListener +(*,
app_name: str,
ack_function: Callable[..., Awaitable[BoltResponse | None]],
lazy_functions: Sequence[Callable[..., Awaitable[None]]],
matchers: Sequence[AsyncListenerMatcher],
middleware: Sequence[AsyncMiddleware],
auto_acknowledgement: bool = False,
ack_timeout: int = 3,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncCustomListener(AsyncListener):
+    app_name: str
+    ack_function: Callable[..., Awaitable[Optional[BoltResponse]]]  # type: ignore[assignment]
+    lazy_functions: Sequence[Callable[..., Awaitable[None]]]
+    matchers: Sequence[AsyncListenerMatcher]
+    middleware: Sequence[AsyncMiddleware]
+    auto_acknowledgement: bool
+    ack_timeout: int
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        *,
+        app_name: str,
+        ack_function: Callable[..., Awaitable[Optional[BoltResponse]]],
+        lazy_functions: Sequence[Callable[..., Awaitable[None]]],
+        matchers: Sequence[AsyncListenerMatcher],
+        middleware: Sequence[AsyncMiddleware],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+        base_logger: Optional[Logger] = None,
+    ):
+        self.app_name = app_name
+        self.ack_function = ack_function
+        self.lazy_functions = lazy_functions
+        self.matchers = matchers
+        self.middleware = middleware
+        self.auto_acknowledgement = auto_acknowledgement
+        self.ack_timeout = ack_timeout
+        self.arg_names = get_arg_names_of_callable(ack_function)
+        self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger)
+
+    async def run_ack_function(
+        self,
+        *,
+        request: AsyncBoltRequest,
+        response: BoltResponse,
+    ) -> Optional[BoltResponse]:
+        return await self.ack_function(
+            **build_async_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=request,
+                response=response,
+                this_func=self.ack_function,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var ack_function : Callable[..., Awaitable[BoltResponse | None]]
+
+

The type of the None singleton.

+
+
var ack_timeout : int
+
+

The type of the None singleton.

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var auto_acknowledgement : bool
+
+

The type of the None singleton.

+
+
var lazy_functions : Sequence[Callable[..., Awaitable[None]]]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var matchers : Sequence[AsyncListenerMatcher]
+
+

The type of the None singleton.

+
+
var middleware : Sequence[AsyncMiddleware]
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def run_ack_function(self,
*,
request: AsyncBoltRequest,
response: BoltResponse) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
async def run_ack_function(
+    self,
+    *,
+    request: AsyncBoltRequest,
+    response: BoltResponse,
+) -> Optional[BoltResponse]:
+    return await self.ack_function(
+        **build_async_required_kwargs(
+            logger=self.logger,
+            required_arg_names=self.arg_names,
+            request=request,
+            response=response,
+            this_func=self.ack_function,
+        )
+    )
+
+

Runs all the registered middleware and then run the listener function.

+

Args

+
+
request
+
The incoming request
+
response
+
The current response
+
+

Returns

+

The processed response

+
+
+
+
+class cls +(*,
app_name: str,
ack_function: Callable[..., Awaitable[BoltResponse | None]],
lazy_functions: Sequence[Callable[..., Awaitable[None]]],
matchers: Sequence[AsyncListenerMatcher],
middleware: Sequence[AsyncMiddleware],
auto_acknowledgement: bool = False,
ack_timeout: int = 3,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncCustomListener(AsyncListener):
+    app_name: str
+    ack_function: Callable[..., Awaitable[Optional[BoltResponse]]]  # type: ignore[assignment]
+    lazy_functions: Sequence[Callable[..., Awaitable[None]]]
+    matchers: Sequence[AsyncListenerMatcher]
+    middleware: Sequence[AsyncMiddleware]
+    auto_acknowledgement: bool
+    ack_timeout: int
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        *,
+        app_name: str,
+        ack_function: Callable[..., Awaitable[Optional[BoltResponse]]],
+        lazy_functions: Sequence[Callable[..., Awaitable[None]]],
+        matchers: Sequence[AsyncListenerMatcher],
+        middleware: Sequence[AsyncMiddleware],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+        base_logger: Optional[Logger] = None,
+    ):
+        self.app_name = app_name
+        self.ack_function = ack_function
+        self.lazy_functions = lazy_functions
+        self.matchers = matchers
+        self.middleware = middleware
+        self.auto_acknowledgement = auto_acknowledgement
+        self.ack_timeout = ack_timeout
+        self.arg_names = get_arg_names_of_callable(ack_function)
+        self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger)
+
+    async def run_ack_function(
+        self,
+        *,
+        request: AsyncBoltRequest,
+        response: BoltResponse,
+    ) -> Optional[BoltResponse]:
+        return await self.ack_function(
+            **build_async_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=request,
+                response=response,
+                this_func=self.ack_function,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class AsyncListener +
+
+
+ +Expand source code + +
class AsyncListener(metaclass=ABCMeta):
+    matchers: Sequence[AsyncListenerMatcher]
+    middleware: Sequence[AsyncMiddleware]
+    ack_function: Callable[..., Awaitable[BoltResponse]]
+    lazy_functions: Sequence[Callable[..., Awaitable[None]]]
+    auto_acknowledgement: bool
+    ack_timeout: int
+
+    async def async_matches(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+    ) -> bool:
+        is_matched: bool = False
+        for matcher in self.matchers:
+            is_matched = await matcher.async_matches(req, resp)
+            if not is_matched:
+                return is_matched
+        return is_matched
+
+    async def run_async_middleware(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+    ) -> Tuple[Optional[BoltResponse], bool]:
+        """Runs an async middleware.
+
+        Args:
+            req: The incoming request
+            resp: The current response
+
+        Returns:
+            A tuple of the processed response and a flag indicating termination
+        """
+        for m in self.middleware:
+            middleware_state = {"next_called": False}
+
+            async def _next():
+                middleware_state["next_called"] = True
+
+            resp = await m.async_process(req=req, resp=resp, next=_next)  # type: ignore[assignment]
+            if not middleware_state["next_called"]:
+                # next() was not called in this middleware
+                return (resp, True)
+        return (resp, False)
+
+    @abstractmethod
+    async def run_ack_function(self, *, request: AsyncBoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+        """Runs all the registered middleware and then run the listener function.
+
+        Args:
+            request: The incoming request
+            response: The current response
+
+        Returns:
+            The processed response
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Class variables

+
+
var ack_function : Callable[..., Awaitable[BoltResponse]]
+
+

The type of the None singleton.

+
+
var ack_timeout : int
+
+

The type of the None singleton.

+
+
var auto_acknowledgement : bool
+
+

The type of the None singleton.

+
+
var lazy_functions : Sequence[Callable[..., Awaitable[None]]]
+
+

The type of the None singleton.

+
+
var matchers : Sequence[AsyncListenerMatcher]
+
+

The type of the None singleton.

+
+
var middleware : Sequence[AsyncMiddleware]
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def async_matches(self,
*,
req: AsyncBoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
async def async_matches(
+    self,
+    *,
+    req: AsyncBoltRequest,
+    resp: BoltResponse,
+) -> bool:
+    is_matched: bool = False
+    for matcher in self.matchers:
+        is_matched = await matcher.async_matches(req, resp)
+        if not is_matched:
+            return is_matched
+    return is_matched
+
+
+
+
+async def run_ack_function(self,
*,
request: AsyncBoltRequest,
response: BoltResponse) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+async def run_ack_function(self, *, request: AsyncBoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+    """Runs all the registered middleware and then run the listener function.
+
+    Args:
+        request: The incoming request
+        response: The current response
+
+    Returns:
+        The processed response
+    """
+    raise NotImplementedError()
+
+

Runs all the registered middleware and then run the listener function.

+

Args

+
+
request
+
The incoming request
+
response
+
The current response
+
+

Returns

+

The processed response

+
+
+async def run_async_middleware(self,
*,
req: AsyncBoltRequest,
resp: BoltResponse) ‑> Tuple[BoltResponse | None, bool]
+
+
+
+ +Expand source code + +
async def run_async_middleware(
+    self,
+    *,
+    req: AsyncBoltRequest,
+    resp: BoltResponse,
+) -> Tuple[Optional[BoltResponse], bool]:
+    """Runs an async middleware.
+
+    Args:
+        req: The incoming request
+        resp: The current response
+
+    Returns:
+        A tuple of the processed response and a flag indicating termination
+    """
+    for m in self.middleware:
+        middleware_state = {"next_called": False}
+
+        async def _next():
+            middleware_state["next_called"] = True
+
+        resp = await m.async_process(req=req, resp=resp, next=_next)  # type: ignore[assignment]
+        if not middleware_state["next_called"]:
+            # next() was not called in this middleware
+            return (resp, True)
+    return (resp, False)
+
+

Runs an async middleware.

+

Args

+
+
req
+
The incoming request
+
resp
+
The current response
+
+

Returns

+

A tuple of the processed response and a flag indicating termination

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/async_listener_completion_handler.html b/docs/reference/listener/async_listener_completion_handler.html new file mode 100644 index 000000000..6cde66b93 --- /dev/null +++ b/docs/reference/listener/async_listener_completion_handler.html @@ -0,0 +1,226 @@ + + + + + + +slack_bolt.listener.async_listener_completion_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.async_listener_completion_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncCustomListenerCompletionHandler +(logger: logging.Logger, func: Callable[..., Awaitable[None]]) +
+
+
+ +Expand source code + +
class AsyncCustomListenerCompletionHandler(AsyncListenerCompletionHandler):
+    def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
+        self.func = func
+        self.logger = logger
+        self.arg_names = get_arg_names_of_callable(func)
+
+    async def handle(
+        self,
+        request: AsyncBoltRequest,
+        response: Optional[BoltResponse],
+    ) -> None:
+        kwargs: Dict[str, Any] = build_async_required_kwargs(
+            required_arg_names=self.arg_names,
+            logger=self.logger,
+            request=request,
+            response=response,
+            next_keys_required=False,
+        )
+        await self.func(**kwargs)
+
+
+

Ancestors

+ +

Inherited members

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

Ancestors

+ +

Inherited members

+ +
+
+class AsyncListenerCompletionHandler +
+
+
+ +Expand source code + +
class AsyncListenerCompletionHandler(metaclass=ABCMeta):
+    @abstractmethod
+    async def handle(
+        self,
+        request: AsyncBoltRequest,
+        response: Optional[BoltResponse],
+    ) -> None:
+        """Do something extra after the listener execution
+
+        Args:
+            request: The request.
+            response: The response.
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+async def handle(self,
request: AsyncBoltRequest,
response: BoltResponse | None) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+async def handle(
+    self,
+    request: AsyncBoltRequest,
+    response: Optional[BoltResponse],
+) -> None:
+    """Do something extra after the listener execution
+
+    Args:
+        request: The request.
+        response: The response.
+    """
+    raise NotImplementedError()
+
+

Do something extra after the listener execution

+

Args

+
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/async_listener_error_handler.html b/docs/reference/listener/async_listener_error_handler.html new file mode 100644 index 000000000..ebee4441a --- /dev/null +++ b/docs/reference/listener/async_listener_error_handler.html @@ -0,0 +1,241 @@ + + + + + + +slack_bolt.listener.async_listener_error_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.async_listener_error_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncCustomListenerErrorHandler +(logger: logging.Logger,
func: Callable[..., Awaitable[BoltResponse | None]])
+
+
+
+ +Expand source code + +
class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler):
+    def __init__(self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]):
+        self.func = func
+        self.logger = logger
+        self.arg_names = get_arg_names_of_callable(func)
+
+    async def handle(
+        self,
+        error: Exception,
+        request: AsyncBoltRequest,
+        response: Optional[BoltResponse],
+    ) -> None:
+        kwargs: Dict[str, Any] = build_async_required_kwargs(
+            required_arg_names=self.arg_names,
+            logger=self.logger,
+            error=error,
+            request=request,
+            response=response,
+            next_keys_required=False,
+        )
+        returned_response = await self.func(**kwargs)
+        if returned_response is not None and isinstance(returned_response, BoltResponse):
+            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

+ +

Inherited members

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

Ancestors

+ +

Inherited members

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

Subclasses

+ +

Methods

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

Handles an unhandled exception.

+

Args

+
+
error
+
The raised exception.
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/async_listener_start_handler.html b/docs/reference/listener/async_listener_start_handler.html new file mode 100644 index 000000000..80b25eb29 --- /dev/null +++ b/docs/reference/listener/async_listener_start_handler.html @@ -0,0 +1,226 @@ + + + + + + +slack_bolt.listener.async_listener_start_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.async_listener_start_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncCustomListenerStartHandler +(logger: logging.Logger, func: Callable[..., Awaitable[None]]) +
+
+
+ +Expand source code + +
class AsyncCustomListenerStartHandler(AsyncListenerStartHandler):
+    def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]):
+        self.func = func
+        self.logger = logger
+        self.arg_names = get_arg_names_of_callable(func)
+
+    async def handle(
+        self,
+        request: AsyncBoltRequest,
+        response: Optional[BoltResponse],
+    ) -> None:
+        kwargs: Dict[str, Any] = build_async_required_kwargs(
+            required_arg_names=self.arg_names,
+            logger=self.logger,
+            request=request,
+            response=response,
+            next_keys_required=False,
+        )
+        await self.func(**kwargs)
+
+
+

Ancestors

+ +

Inherited members

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

Ancestors

+ +

Inherited members

+ +
+
+class AsyncListenerStartHandler +
+
+
+ +Expand source code + +
class AsyncListenerStartHandler(metaclass=ABCMeta):
+    @abstractmethod
+    async def handle(
+        self,
+        request: AsyncBoltRequest,
+        response: Optional[BoltResponse],
+    ) -> None:
+        """Do something extra before the listener execution
+
+        Args:
+            request: The request.
+            response: The response.
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+async def handle(self,
request: AsyncBoltRequest,
response: BoltResponse | None) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+async def handle(
+    self,
+    request: AsyncBoltRequest,
+    response: Optional[BoltResponse],
+) -> None:
+    """Do something extra before the listener execution
+
+    Args:
+        request: The request.
+        response: The response.
+    """
+    raise NotImplementedError()
+
+

Do something extra before the listener execution

+

Args

+
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/asyncio_runner.html b/docs/reference/listener/asyncio_runner.html new file mode 100644 index 000000000..4d71a88a7 --- /dev/null +++ b/docs/reference/listener/asyncio_runner.html @@ -0,0 +1,420 @@ + + + + + + +slack_bolt.listener.asyncio_runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.asyncio_runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncioListenerRunner +(logger: logging.Logger,
process_before_response: bool,
listener_error_handler: AsyncListenerErrorHandler,
listener_start_handler: AsyncListenerStartHandler,
listener_completion_handler: AsyncListenerCompletionHandler,
lazy_listener_runner: AsyncLazyListenerRunner)
+
+
+
+ +Expand source code + +
class AsyncioListenerRunner:
+    logger: Logger
+    process_before_response: bool
+    listener_error_handler: AsyncListenerErrorHandler
+    listener_start_handler: AsyncListenerStartHandler
+    listener_completion_handler: AsyncListenerCompletionHandler
+    lazy_listener_runner: AsyncLazyListenerRunner
+
+    def __init__(
+        self,
+        logger: Logger,
+        process_before_response: bool,
+        listener_error_handler: AsyncListenerErrorHandler,
+        listener_start_handler: AsyncListenerStartHandler,
+        listener_completion_handler: AsyncListenerCompletionHandler,
+        lazy_listener_runner: AsyncLazyListenerRunner,
+    ):
+        self.logger = logger
+        self.process_before_response = process_before_response
+        self.listener_error_handler = listener_error_handler
+        self.listener_start_handler = listener_start_handler
+        self.listener_completion_handler = listener_completion_handler
+        self.lazy_listener_runner = lazy_listener_runner
+
+    async def run(
+        self,
+        request: AsyncBoltRequest,
+        response: BoltResponse,
+        listener_name: str,
+        listener: AsyncListener,
+        starting_time: Optional[float] = None,
+    ) -> Optional[BoltResponse]:
+        ack = request.context.ack
+        starting_time = starting_time if starting_time is not None else time.time()
+        if self.process_before_response:
+            if not request.lazy_only:
+                try:
+                    await self.listener_start_handler.handle(request=request, response=response)
+                    returned_value = await listener.run_ack_function(request=request, response=response)
+                    if isinstance(returned_value, BoltResponse):
+                        response = returned_value
+                    if ack.response is None and listener.auto_acknowledgement:
+                        await ack()  # automatic ack() call if the call is not yet done
+                except Exception as e:
+                    # The default response status code is 500 in this case.
+                    # You can customize this by passing your own error handler.
+                    if response is None:
+                        response = BoltResponse(status=500)
+                    response.status = 500
+                    await self.listener_error_handler.handle(
+                        error=e,
+                        request=request,
+                        response=response,
+                    )
+                    ack.response = response
+                finally:
+                    await self.listener_completion_handler.handle(request=request, response=response)
+
+            for lazy_func in listener.lazy_functions:
+                if request.lazy_function_name:
+                    func_name = get_name_for_callable(lazy_func)
+                    if func_name == request.lazy_function_name:
+                        await self.lazy_listener_runner.run(function=lazy_func, request=request)
+                        # This HTTP response won't be sent to Slack API servers.
+                        return BoltResponse(status=200)
+                    else:
+                        continue
+                else:
+                    self._start_lazy_function(lazy_func, request)
+
+            if response is not None:
+                self._debug_log_completion(starting_time, response)
+                return response
+            elif ack.response is not None:
+                self._debug_log_completion(starting_time, ack.response)
+                return ack.response
+        else:
+            if listener.auto_acknowledgement:
+                # acknowledge immediately in case of Events API
+                await ack()
+
+            if not request.lazy_only:
+                # start the listener function asynchronously
+                # NOTE: intentionally
+                async def run_ack_function_asynchronously(
+                    ack: AsyncAck,
+                    request: AsyncBoltRequest,
+                    response: BoltResponse,
+                ):
+                    try:
+                        await self.listener_start_handler.handle(request=request, response=response)
+                        await listener.run_ack_function(request=request, response=response)
+                    except Exception as e:
+                        # The default response status code is 500 in this case.
+                        # You can customize this by passing your own error handler.
+                        if response is None:
+                            response = BoltResponse(status=500)
+                        response.status = 500
+                        if ack.response is not None:  # already acknowledged
+                            response = None  # type: ignore[assignment]
+
+                        await self.listener_error_handler.handle(
+                            error=e,
+                            request=request,
+                            response=response,
+                        )
+                        ack.response = response
+                    finally:
+                        await self.listener_completion_handler.handle(request=request, response=response)
+
+                _f: Future = asyncio.ensure_future(run_ack_function_asynchronously(ack, request, response))
+
+            for lazy_func in listener.lazy_functions:
+                if request.lazy_function_name:
+                    func_name = get_name_for_callable(lazy_func)
+                    if func_name == request.lazy_function_name:
+                        await self.lazy_listener_runner.run(function=lazy_func, request=request)
+                        # This HTTP response won't be sent to Slack API servers.
+                        return BoltResponse(status=200)
+                    else:
+                        continue
+                else:
+                    self._start_lazy_function(lazy_func, request)
+
+            # await for the completion of ack() in the async listener execution
+            while ack.response is None and time.time() - starting_time <= listener.ack_timeout:
+                await asyncio.sleep(0.01)
+
+            if response is None and ack.response is None:
+                self.logger.warning(warning_did_not_call_ack(listener_name))
+                return None
+
+            if response is None and ack.response is not None:
+                response = ack.response
+                self._debug_log_completion(starting_time, response)
+                return response
+
+            if response is not None:
+                return response
+
+        # None for both means no ack() in the listener
+        return None
+
+    def _start_lazy_function(self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None:
+        # Start a lazy function asynchronously
+        func_name: str = get_name_for_callable(lazy_func)
+        self.logger.debug(debug_running_lazy_listener(func_name))
+        copied_request = self._build_lazy_request(request, func_name)
+        self.lazy_listener_runner.start(function=lazy_func, request=copied_request)
+
+    def _build_lazy_request(self, request: AsyncBoltRequest, lazy_func_name: str) -> AsyncBoltRequest:
+        copied_request: AsyncBoltRequest = create_copy(request.to_copyable())
+        copied_request.lazy_only = True
+        copied_request.lazy_function_name = lazy_func_name
+        copied_request.context["listener_runner"] = self
+        if request.context.get_thread_context is not None:
+            copied_request.context["get_thread_context"] = request.context.get_thread_context
+        if request.context.save_thread_context is not None:
+            copied_request.context["save_thread_context"] = request.context.save_thread_context
+        return copied_request
+
+    def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None:
+        millis = int((time.time() - starting_time) * 1000)
+        self.logger.debug(debug_responding(response.status, response.body, millis))
+
+
+

Class variables

+
+
var lazy_listener_runnerAsyncLazyListenerRunner
+
+

The type of the None singleton.

+
+
var listener_completion_handlerAsyncListenerCompletionHandler
+
+

The type of the None singleton.

+
+
var listener_error_handlerAsyncListenerErrorHandler
+
+

The type of the None singleton.

+
+
var listener_start_handlerAsyncListenerStartHandler
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var process_before_response : bool
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def run(self,
request: AsyncBoltRequest,
response: BoltResponse,
listener_name: str,
listener: AsyncListener,
starting_time: float | None = None) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
async def run(
+    self,
+    request: AsyncBoltRequest,
+    response: BoltResponse,
+    listener_name: str,
+    listener: AsyncListener,
+    starting_time: Optional[float] = None,
+) -> Optional[BoltResponse]:
+    ack = request.context.ack
+    starting_time = starting_time if starting_time is not None else time.time()
+    if self.process_before_response:
+        if not request.lazy_only:
+            try:
+                await self.listener_start_handler.handle(request=request, response=response)
+                returned_value = await listener.run_ack_function(request=request, response=response)
+                if isinstance(returned_value, BoltResponse):
+                    response = returned_value
+                if ack.response is None and listener.auto_acknowledgement:
+                    await ack()  # automatic ack() call if the call is not yet done
+            except Exception as e:
+                # The default response status code is 500 in this case.
+                # You can customize this by passing your own error handler.
+                if response is None:
+                    response = BoltResponse(status=500)
+                response.status = 500
+                await self.listener_error_handler.handle(
+                    error=e,
+                    request=request,
+                    response=response,
+                )
+                ack.response = response
+            finally:
+                await self.listener_completion_handler.handle(request=request, response=response)
+
+        for lazy_func in listener.lazy_functions:
+            if request.lazy_function_name:
+                func_name = get_name_for_callable(lazy_func)
+                if func_name == request.lazy_function_name:
+                    await self.lazy_listener_runner.run(function=lazy_func, request=request)
+                    # This HTTP response won't be sent to Slack API servers.
+                    return BoltResponse(status=200)
+                else:
+                    continue
+            else:
+                self._start_lazy_function(lazy_func, request)
+
+        if response is not None:
+            self._debug_log_completion(starting_time, response)
+            return response
+        elif ack.response is not None:
+            self._debug_log_completion(starting_time, ack.response)
+            return ack.response
+    else:
+        if listener.auto_acknowledgement:
+            # acknowledge immediately in case of Events API
+            await ack()
+
+        if not request.lazy_only:
+            # start the listener function asynchronously
+            # NOTE: intentionally
+            async def run_ack_function_asynchronously(
+                ack: AsyncAck,
+                request: AsyncBoltRequest,
+                response: BoltResponse,
+            ):
+                try:
+                    await self.listener_start_handler.handle(request=request, response=response)
+                    await listener.run_ack_function(request=request, response=response)
+                except Exception as e:
+                    # The default response status code is 500 in this case.
+                    # You can customize this by passing your own error handler.
+                    if response is None:
+                        response = BoltResponse(status=500)
+                    response.status = 500
+                    if ack.response is not None:  # already acknowledged
+                        response = None  # type: ignore[assignment]
+
+                    await self.listener_error_handler.handle(
+                        error=e,
+                        request=request,
+                        response=response,
+                    )
+                    ack.response = response
+                finally:
+                    await self.listener_completion_handler.handle(request=request, response=response)
+
+            _f: Future = asyncio.ensure_future(run_ack_function_asynchronously(ack, request, response))
+
+        for lazy_func in listener.lazy_functions:
+            if request.lazy_function_name:
+                func_name = get_name_for_callable(lazy_func)
+                if func_name == request.lazy_function_name:
+                    await self.lazy_listener_runner.run(function=lazy_func, request=request)
+                    # This HTTP response won't be sent to Slack API servers.
+                    return BoltResponse(status=200)
+                else:
+                    continue
+            else:
+                self._start_lazy_function(lazy_func, request)
+
+        # await for the completion of ack() in the async listener execution
+        while ack.response is None and time.time() - starting_time <= listener.ack_timeout:
+            await asyncio.sleep(0.01)
+
+        if response is None and ack.response is None:
+            self.logger.warning(warning_did_not_call_ack(listener_name))
+            return None
+
+        if response is None and ack.response is not None:
+            response = ack.response
+            self._debug_log_completion(starting_time, response)
+            return response
+
+        if response is not None:
+            return response
+
+    # None for both means no ack() in the listener
+    return None
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/builtins.html b/docs/reference/listener/builtins.html new file mode 100644 index 000000000..5f3759658 --- /dev/null +++ b/docs/reference/listener/builtins.html @@ -0,0 +1,174 @@ + + + + + + +slack_bolt.listener.builtins API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.builtins

+
+
+
+
+
+
+
+
+
+
+

Classes

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

Listener functions to handle token revocation / uninstallation events

+

Class variables

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

The type of the None singleton.

+
+
+

Methods

+
+
+def handle_app_uninstalled_events(self,
context: BoltContext) ‑> None
+
+
+
+ +Expand source code + +
def handle_app_uninstalled_events(self, context: BoltContext) -> None:
+    self.installation_store.delete_all(
+        enterprise_id=context.enterprise_id,
+        team_id=context.team_id,
+    )
+
+
+
+
+def handle_tokens_revoked_events(self,
event: dict,
context: BoltContext) ‑> None
+
+
+
+ +Expand source code + +
def handle_tokens_revoked_events(self, event: dict, context: BoltContext) -> None:
+    user_ids = event.get("tokens", {}).get("oauth", [])
+    if len(user_ids) > 0:
+        for user_id in user_ids:
+            self.installation_store.delete_installation(
+                enterprise_id=context.enterprise_id,
+                team_id=context.team_id,
+                user_id=user_id,
+            )
+    bots = event.get("tokens", {}).get("bot", [])
+    if len(bots) > 0:
+        self.installation_store.delete_bot(
+            enterprise_id=context.enterprise_id,
+            team_id=context.team_id,
+        )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/custom_listener.html b/docs/reference/listener/custom_listener.html new file mode 100644 index 000000000..1f18502f2 --- /dev/null +++ b/docs/reference/listener/custom_listener.html @@ -0,0 +1,175 @@ + + + + + + +slack_bolt.listener.custom_listener API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.custom_listener

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomListener +(*,
app_name: str,
ack_function: Callable[..., BoltResponse | None],
lazy_functions: Sequence[Callable[..., None]],
matchers: Sequence[ListenerMatcher],
middleware: Sequence[Middleware],
auto_acknowledgement: bool = False,
ack_timeout: int = 3,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class CustomListener(Listener):
+    app_name: str
+    ack_function: Callable[..., Optional[BoltResponse]]  # type: ignore[assignment]
+    lazy_functions: Sequence[Callable[..., None]]
+    matchers: Sequence[ListenerMatcher]
+    middleware: Sequence[Middleware]
+    auto_acknowledgement: bool
+    ack_timeout: int = 3
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        *,
+        app_name: str,
+        ack_function: Callable[..., Optional[BoltResponse]],
+        lazy_functions: Sequence[Callable[..., None]],
+        matchers: Sequence[ListenerMatcher],
+        middleware: Sequence[Middleware],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+        base_logger: Optional[Logger] = None,
+    ):
+        self.app_name = app_name
+        self.ack_function = ack_function
+        self.lazy_functions = lazy_functions
+        self.matchers = matchers
+        self.middleware = middleware
+        self.auto_acknowledgement = auto_acknowledgement
+        self.ack_timeout = ack_timeout
+        self.arg_names = get_arg_names_of_callable(ack_function)
+        self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger)
+
+    def run_ack_function(
+        self,
+        *,
+        request: BoltRequest,
+        response: BoltResponse,
+    ) -> Optional[BoltResponse]:
+        return self.ack_function(
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=request,
+                response=response,
+                this_func=self.ack_function,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/index.html b/docs/reference/listener/index.html new file mode 100644 index 000000000..f31264cac --- /dev/null +++ b/docs/reference/listener/index.html @@ -0,0 +1,471 @@ + + + + + + +slack_bolt.listener API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener

+
+
+

Listeners process an incoming request from Slack if the request's type or data structure matches +the predefined conditions of the listener. Typically, a listener acknowledge requests from Slack, +process the request data, and may send response back to Slack.

+
+
+

Sub-modules

+
+
slack_bolt.listener.async_builtins
+
+
+
+
slack_bolt.listener.async_listener
+
+
+
+
slack_bolt.listener.async_listener_completion_handler
+
+
+
+
slack_bolt.listener.async_listener_error_handler
+
+
+
+
slack_bolt.listener.async_listener_start_handler
+
+
+
+
slack_bolt.listener.asyncio_runner
+
+
+
+
slack_bolt.listener.builtins
+
+
+
+
slack_bolt.listener.custom_listener
+
+
+
+
slack_bolt.listener.listener
+
+
+
+
slack_bolt.listener.listener_completion_handler
+
+
+
+
slack_bolt.listener.listener_error_handler
+
+
+
+
slack_bolt.listener.listener_start_handler
+
+
+
+
slack_bolt.listener.thread_runner
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomListener +(*,
app_name: str,
ack_function: Callable[..., BoltResponse | None],
lazy_functions: Sequence[Callable[..., None]],
matchers: Sequence[ListenerMatcher],
middleware: Sequence[Middleware],
auto_acknowledgement: bool = False,
ack_timeout: int = 3,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class CustomListener(Listener):
+    app_name: str
+    ack_function: Callable[..., Optional[BoltResponse]]  # type: ignore[assignment]
+    lazy_functions: Sequence[Callable[..., None]]
+    matchers: Sequence[ListenerMatcher]
+    middleware: Sequence[Middleware]
+    auto_acknowledgement: bool
+    ack_timeout: int = 3
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        *,
+        app_name: str,
+        ack_function: Callable[..., Optional[BoltResponse]],
+        lazy_functions: Sequence[Callable[..., None]],
+        matchers: Sequence[ListenerMatcher],
+        middleware: Sequence[Middleware],
+        auto_acknowledgement: bool = False,
+        ack_timeout: int = 3,
+        base_logger: Optional[Logger] = None,
+    ):
+        self.app_name = app_name
+        self.ack_function = ack_function
+        self.lazy_functions = lazy_functions
+        self.matchers = matchers
+        self.middleware = middleware
+        self.auto_acknowledgement = auto_acknowledgement
+        self.ack_timeout = ack_timeout
+        self.arg_names = get_arg_names_of_callable(ack_function)
+        self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger)
+
+    def run_ack_function(
+        self,
+        *,
+        request: BoltRequest,
+        response: BoltResponse,
+    ) -> Optional[BoltResponse]:
+        return self.ack_function(
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=request,
+                response=response,
+                this_func=self.ack_function,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class Listener +
+
+
+ +Expand source code + +
class Listener(metaclass=ABCMeta):
+    matchers: Sequence[ListenerMatcher]
+    middleware: Sequence[Middleware]
+    ack_function: Callable[..., BoltResponse]
+    lazy_functions: Sequence[Callable[..., None]]
+    auto_acknowledgement: bool
+    ack_timeout: int = 3
+
+    def matches(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> bool:
+        is_matched: bool = False
+        for matcher in self.matchers:
+            is_matched = matcher.matches(req, resp)
+            if not is_matched:
+                return is_matched
+        return is_matched
+
+    def run_middleware(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> Tuple[Optional[BoltResponse], bool]:
+        """Runs a middleware.
+
+        Args:
+            req: The incoming request
+            resp: The current response
+
+        Returns:
+            A tuple of the processed response and a flag indicating termination
+        """
+        for m in self.middleware:
+            middleware_state = {"next_called": False}
+
+            def next_():
+                middleware_state["next_called"] = True
+
+            resp = m.process(req=req, resp=resp, next=next_)  # type: ignore[assignment]
+            if not middleware_state["next_called"]:
+                # next() was not called in this middleware
+                return (resp, True)
+        return (resp, False)
+
+    @abstractmethod
+    def run_ack_function(self, *, request: BoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+        """Runs all the registered middleware and then run the listener function.
+
+        Args:
+            request: The incoming request
+            response: The current response
+
+        Returns:
+            The processed response
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Class variables

+
+
var ack_function : Callable[..., BoltResponse]
+
+

The type of the None singleton.

+
+
var ack_timeout : int
+
+

The type of the None singleton.

+
+
var auto_acknowledgement : bool
+
+

The type of the None singleton.

+
+
var lazy_functions : Sequence[Callable[..., None]]
+
+

The type of the None singleton.

+
+
var matchers : Sequence[ListenerMatcher]
+
+

The type of the None singleton.

+
+
var middleware : Sequence[Middleware]
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def matches(self,
*,
req: BoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
def matches(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+) -> bool:
+    is_matched: bool = False
+    for matcher in self.matchers:
+        is_matched = matcher.matches(req, resp)
+        if not is_matched:
+            return is_matched
+    return is_matched
+
+
+
+
+def run_ack_function(self,
*,
request: BoltRequest,
response: BoltResponse) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+def run_ack_function(self, *, request: BoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+    """Runs all the registered middleware and then run the listener function.
+
+    Args:
+        request: The incoming request
+        response: The current response
+
+    Returns:
+        The processed response
+    """
+    raise NotImplementedError()
+
+

Runs all the registered middleware and then run the listener function.

+

Args

+
+
request
+
The incoming request
+
response
+
The current response
+
+

Returns

+

The processed response

+
+
+def run_middleware(self,
*,
req: BoltRequest,
resp: BoltResponse) ‑> Tuple[BoltResponse | None, bool]
+
+
+
+ +Expand source code + +
def run_middleware(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+) -> Tuple[Optional[BoltResponse], bool]:
+    """Runs a middleware.
+
+    Args:
+        req: The incoming request
+        resp: The current response
+
+    Returns:
+        A tuple of the processed response and a flag indicating termination
+    """
+    for m in self.middleware:
+        middleware_state = {"next_called": False}
+
+        def next_():
+            middleware_state["next_called"] = True
+
+        resp = m.process(req=req, resp=resp, next=next_)  # type: ignore[assignment]
+        if not middleware_state["next_called"]:
+            # next() was not called in this middleware
+            return (resp, True)
+    return (resp, False)
+
+

Runs a middleware.

+

Args

+
+
req
+
The incoming request
+
resp
+
The current response
+
+

Returns

+

A tuple of the processed response and a flag indicating termination

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/listener.html b/docs/reference/listener/listener.html new file mode 100644 index 000000000..034dbe67f --- /dev/null +++ b/docs/reference/listener/listener.html @@ -0,0 +1,293 @@ + + + + + + +slack_bolt.listener.listener API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.listener

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Listener +
+
+
+ +Expand source code + +
class Listener(metaclass=ABCMeta):
+    matchers: Sequence[ListenerMatcher]
+    middleware: Sequence[Middleware]
+    ack_function: Callable[..., BoltResponse]
+    lazy_functions: Sequence[Callable[..., None]]
+    auto_acknowledgement: bool
+    ack_timeout: int = 3
+
+    def matches(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> bool:
+        is_matched: bool = False
+        for matcher in self.matchers:
+            is_matched = matcher.matches(req, resp)
+            if not is_matched:
+                return is_matched
+        return is_matched
+
+    def run_middleware(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> Tuple[Optional[BoltResponse], bool]:
+        """Runs a middleware.
+
+        Args:
+            req: The incoming request
+            resp: The current response
+
+        Returns:
+            A tuple of the processed response and a flag indicating termination
+        """
+        for m in self.middleware:
+            middleware_state = {"next_called": False}
+
+            def next_():
+                middleware_state["next_called"] = True
+
+            resp = m.process(req=req, resp=resp, next=next_)  # type: ignore[assignment]
+            if not middleware_state["next_called"]:
+                # next() was not called in this middleware
+                return (resp, True)
+        return (resp, False)
+
+    @abstractmethod
+    def run_ack_function(self, *, request: BoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+        """Runs all the registered middleware and then run the listener function.
+
+        Args:
+            request: The incoming request
+            response: The current response
+
+        Returns:
+            The processed response
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Class variables

+
+
var ack_function : Callable[..., BoltResponse]
+
+

The type of the None singleton.

+
+
var ack_timeout : int
+
+

The type of the None singleton.

+
+
var auto_acknowledgement : bool
+
+

The type of the None singleton.

+
+
var lazy_functions : Sequence[Callable[..., None]]
+
+

The type of the None singleton.

+
+
var matchers : Sequence[ListenerMatcher]
+
+

The type of the None singleton.

+
+
var middleware : Sequence[Middleware]
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def matches(self,
*,
req: BoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
def matches(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+) -> bool:
+    is_matched: bool = False
+    for matcher in self.matchers:
+        is_matched = matcher.matches(req, resp)
+        if not is_matched:
+            return is_matched
+    return is_matched
+
+
+
+
+def run_ack_function(self,
*,
request: BoltRequest,
response: BoltResponse) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+def run_ack_function(self, *, request: BoltRequest, response: BoltResponse) -> Optional[BoltResponse]:
+    """Runs all the registered middleware and then run the listener function.
+
+    Args:
+        request: The incoming request
+        response: The current response
+
+    Returns:
+        The processed response
+    """
+    raise NotImplementedError()
+
+

Runs all the registered middleware and then run the listener function.

+

Args

+
+
request
+
The incoming request
+
response
+
The current response
+
+

Returns

+

The processed response

+
+
+def run_middleware(self,
*,
req: BoltRequest,
resp: BoltResponse) ‑> Tuple[BoltResponse | None, bool]
+
+
+
+ +Expand source code + +
def run_middleware(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+) -> Tuple[Optional[BoltResponse], bool]:
+    """Runs a middleware.
+
+    Args:
+        req: The incoming request
+        resp: The current response
+
+    Returns:
+        A tuple of the processed response and a flag indicating termination
+    """
+    for m in self.middleware:
+        middleware_state = {"next_called": False}
+
+        def next_():
+            middleware_state["next_called"] = True
+
+        resp = m.process(req=req, resp=resp, next=next_)  # type: ignore[assignment]
+        if not middleware_state["next_called"]:
+            # next() was not called in this middleware
+            return (resp, True)
+    return (resp, False)
+
+

Runs a middleware.

+

Args

+
+
req
+
The incoming request
+
resp
+
The current response
+
+

Returns

+

A tuple of the processed response and a flag indicating termination

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/listener_completion_handler.html b/docs/reference/listener/listener_completion_handler.html new file mode 100644 index 000000000..42b1b5413 --- /dev/null +++ b/docs/reference/listener/listener_completion_handler.html @@ -0,0 +1,227 @@ + + + + + + +slack_bolt.listener.listener_completion_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.listener_completion_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomListenerCompletionHandler +(logger: logging.Logger, func: Callable[..., None]) +
+
+
+ +Expand source code + +
class CustomListenerCompletionHandler(ListenerCompletionHandler):
+    def __init__(self, logger: Logger, func: Callable[..., None]):
+        self.func = func
+        self.logger = logger
+        self.arg_names = get_arg_names_of_callable(func)
+
+    def handle(
+        self,
+        request: BoltRequest,
+        response: Optional[BoltResponse],
+    ):
+        kwargs: Dict[str, Any] = build_required_kwargs(
+            required_arg_names=self.arg_names,
+            logger=self.logger,
+            request=request,
+            response=response,
+            next_keys_required=False,
+        )
+        self.func(**kwargs)
+
+
+

Ancestors

+ +

Inherited members

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

Ancestors

+ +

Inherited members

+ +
+
+class ListenerCompletionHandler +
+
+
+ +Expand source code + +
class ListenerCompletionHandler(metaclass=ABCMeta):
+    @abstractmethod
+    def handle(
+        self,
+        request: BoltRequest,
+        response: Optional[BoltResponse],
+    ) -> None:
+        """Do something extra after the listener execution
+
+        Args:
+            request: The request.
+            response: The response.
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+def handle(self,
request: BoltRequest,
response: BoltResponse | None) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+def handle(
+    self,
+    request: BoltRequest,
+    response: Optional[BoltResponse],
+) -> None:
+    """Do something extra after the listener execution
+
+    Args:
+        request: The request.
+        response: The response.
+    """
+    raise NotImplementedError()
+
+

Do something extra after the listener execution

+

Args

+
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/listener_error_handler.html b/docs/reference/listener/listener_error_handler.html new file mode 100644 index 000000000..e344b15cb --- /dev/null +++ b/docs/reference/listener/listener_error_handler.html @@ -0,0 +1,241 @@ + + + + + + +slack_bolt.listener.listener_error_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.listener_error_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomListenerErrorHandler +(logger: logging.Logger,
func: Callable[..., BoltResponse | None])
+
+
+
+ +Expand source code + +
class CustomListenerErrorHandler(ListenerErrorHandler):
+    def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]):
+        self.func = func
+        self.logger = logger
+        self.arg_names = get_arg_names_of_callable(func)
+
+    def handle(
+        self,
+        error: Exception,
+        request: BoltRequest,
+        response: Optional[BoltResponse],
+    ):
+        kwargs: Dict[str, Any] = build_required_kwargs(
+            required_arg_names=self.arg_names,
+            logger=self.logger,
+            error=error,
+            request=request,
+            response=response,
+            next_keys_required=False,
+        )
+        returned_response = self.func(**kwargs)
+        if returned_response is not None and isinstance(returned_response, BoltResponse):
+            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

+ +

Inherited members

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

Ancestors

+ +

Inherited members

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

Subclasses

+ +

Methods

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

Handles an unhandled exception.

+

Args

+
+
error
+
The raised exception.
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/listener_start_handler.html b/docs/reference/listener/listener_start_handler.html new file mode 100644 index 000000000..d60c1b9dc --- /dev/null +++ b/docs/reference/listener/listener_start_handler.html @@ -0,0 +1,238 @@ + + + + + + +slack_bolt.listener.listener_start_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.listener_start_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomListenerStartHandler +(logger: logging.Logger, func: Callable[..., None]) +
+
+
+ +Expand source code + +
class CustomListenerStartHandler(ListenerStartHandler):
+    def __init__(self, logger: Logger, func: Callable[..., None]):
+        self.func = func
+        self.logger = logger
+        self.arg_names = get_arg_names_of_callable(func)
+
+    def handle(
+        self,
+        request: BoltRequest,
+        response: Optional[BoltResponse],
+    ):
+        kwargs: Dict[str, Any] = build_required_kwargs(
+            required_arg_names=self.arg_names,
+            logger=self.logger,
+            request=request,
+            response=response,
+            next_keys_required=False,
+        )
+        self.func(**kwargs)
+
+
+

Ancestors

+ +

Inherited members

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

Ancestors

+ +

Inherited members

+ +
+
+class ListenerStartHandler +
+
+
+ +Expand source code + +
class ListenerStartHandler(metaclass=ABCMeta):
+    @abstractmethod
+    def handle(
+        self,
+        request: BoltRequest,
+        response: Optional[BoltResponse],
+    ) -> None:
+        """Do something extra before the listener execution.
+
+        This handler is useful if a developer needs to maintain/clean up
+        thread-local resources such as Django ORM database connections
+        before a listener execution starts.
+
+        Args:
+            request: The request.
+            response: The response.
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+def handle(self,
request: BoltRequest,
response: BoltResponse | None) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+def handle(
+    self,
+    request: BoltRequest,
+    response: Optional[BoltResponse],
+) -> None:
+    """Do something extra before the listener execution.
+
+    This handler is useful if a developer needs to maintain/clean up
+    thread-local resources such as Django ORM database connections
+    before a listener execution starts.
+
+    Args:
+        request: The request.
+        response: The response.
+    """
+    raise NotImplementedError()
+
+

Do something extra before the listener execution.

+

This handler is useful if a developer needs to maintain/clean up +thread-local resources such as Django ORM database connections +before a listener execution starts.

+

Args

+
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener/thread_runner.html b/docs/reference/listener/thread_runner.html new file mode 100644 index 000000000..5415f9ada --- /dev/null +++ b/docs/reference/listener/thread_runner.html @@ -0,0 +1,457 @@ + + + + + + +slack_bolt.listener.thread_runner API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener.thread_runner

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class ThreadListenerRunner +(logger: logging.Logger,
process_before_response: bool,
listener_error_handler: ListenerErrorHandler,
listener_start_handler: ListenerStartHandler,
listener_completion_handler: ListenerCompletionHandler,
listener_executor: concurrent.futures._base.Executor,
lazy_listener_runner: LazyListenerRunner)
+
+
+
+ +Expand source code + +
class ThreadListenerRunner:
+    logger: Logger
+    process_before_response: bool
+    listener_error_handler: ListenerErrorHandler
+    listener_start_handler: ListenerStartHandler
+    listener_completion_handler: ListenerCompletionHandler
+    listener_executor: Executor
+    lazy_listener_runner: LazyListenerRunner
+
+    def __init__(
+        self,
+        logger: Logger,
+        process_before_response: bool,
+        listener_error_handler: ListenerErrorHandler,
+        listener_start_handler: ListenerStartHandler,
+        listener_completion_handler: ListenerCompletionHandler,
+        listener_executor: Executor,
+        lazy_listener_runner: LazyListenerRunner,
+    ):
+        self.logger = logger
+        self.process_before_response = process_before_response
+        self.listener_error_handler = listener_error_handler
+        self.listener_start_handler = listener_start_handler
+        self.listener_completion_handler = listener_completion_handler
+        self.listener_executor = listener_executor
+        self.lazy_listener_runner = lazy_listener_runner
+
+    def run(
+        self,
+        request: BoltRequest,
+        response: BoltResponse,
+        listener_name: str,
+        listener: Listener,
+        starting_time: Optional[float] = None,
+    ) -> Optional[BoltResponse]:
+        ack = request.context.ack
+        starting_time = starting_time if starting_time is not None else time.time()
+        if self.process_before_response:
+            if not request.lazy_only:
+                try:
+                    self.listener_start_handler.handle(
+                        request=request,
+                        response=response,
+                    )
+                    returned_value = listener.run_ack_function(request=request, response=response)
+                    if isinstance(returned_value, BoltResponse):
+                        response = returned_value
+                    if ack.response is None and listener.auto_acknowledgement:
+                        ack()  # automatic ack() call if the call is not yet done
+                except Exception as e:
+                    # The default response status code is 500 in this case.
+                    # You can customize this by passing your own error handler.
+                    if response is None:
+                        response = BoltResponse(status=500)
+                    response.status = 500
+                    self.listener_error_handler.handle(
+                        error=e,
+                        request=request,
+                        response=response,
+                    )
+                    ack.response = response
+                finally:
+                    self.listener_completion_handler.handle(
+                        request=request,
+                        response=response,
+                    )
+
+            for lazy_func in listener.lazy_functions:
+                if request.lazy_function_name:
+                    func_name = get_name_for_callable(lazy_func)
+                    if func_name == request.lazy_function_name:
+                        self.lazy_listener_runner.run(function=lazy_func, request=request)
+                        # This HTTP response won't be sent to Slack API servers.
+                        return BoltResponse(status=200)
+                    else:
+                        continue
+                else:
+                    self._start_lazy_function(lazy_func, request)
+
+            if response is not None:
+                self._debug_log_completion(starting_time, response)
+                return response
+            elif ack.response is not None:
+                self._debug_log_completion(starting_time, ack.response)
+                return ack.response
+        else:
+            if listener.auto_acknowledgement:
+                # acknowledge immediately in case of Events API
+                ack()
+
+            if not request.lazy_only:
+                # start the listener function asynchronously
+                def run_ack_function_asynchronously():
+                    nonlocal response
+                    try:
+                        self.listener_start_handler.handle(
+                            request=request,
+                            response=response,
+                        )
+                        listener.run_ack_function(request=request, response=response)
+                    except Exception as e:
+                        # The default response status code is 500 in this case.
+                        # You can customize this by passing your own error handler.
+                        if listener.auto_acknowledgement:
+                            self.listener_error_handler.handle(
+                                error=e,
+                                request=request,
+                                response=response,
+                            )
+                        else:
+                            if response is None:
+                                response = BoltResponse(status=500)
+                            response.status = 500
+                            if ack.response is not None:  # already acknowledged
+                                response = None
+                            self.listener_error_handler.handle(
+                                error=e,
+                                request=request,
+                                response=response,
+                            )
+                            ack.response = response
+                    finally:
+                        self.listener_completion_handler.handle(
+                            request=request,
+                            response=response,
+                        )
+
+                self.listener_executor.submit(run_ack_function_asynchronously)
+
+            for lazy_func in listener.lazy_functions:
+                if request.lazy_function_name:
+                    func_name = get_name_for_callable(lazy_func)
+                    if func_name == request.lazy_function_name:
+                        self.lazy_listener_runner.run(function=lazy_func, request=request)
+                        # This HTTP response won't be sent to Slack API servers.
+                        return BoltResponse(status=200)
+                    else:
+                        continue
+                else:
+                    self._start_lazy_function(lazy_func, request)
+
+            # await for the completion of ack() in the async listener execution
+            while ack.response is None and time.time() - starting_time <= listener.ack_timeout:
+                time.sleep(0.01)
+
+            if response is None and ack.response is None:
+                self.logger.warning(warning_did_not_call_ack(listener_name))
+                return None
+
+            if response is None and ack.response is not None:
+                response = ack.response
+                self._debug_log_completion(starting_time, response)
+                return response
+
+            if response is not None:
+                return response
+
+        # None for both means no ack() in the listener
+        return None
+
+    def _start_lazy_function(self, lazy_func: Callable[..., None], request: BoltRequest) -> None:
+        # Start a lazy function asynchronously
+        func_name: str = get_name_for_callable(lazy_func)
+        self.logger.debug(debug_running_lazy_listener(func_name))
+        copied_request = self._build_lazy_request(request, func_name)
+        self.lazy_listener_runner.start(function=lazy_func, request=copied_request)
+
+    def _build_lazy_request(self, request: BoltRequest, lazy_func_name: str) -> BoltRequest:
+        copied_request: BoltRequest = create_copy(request.to_copyable())
+        copied_request.lazy_only = True
+        copied_request.lazy_function_name = lazy_func_name
+        # These are not copyable objects, so manually set for a different thread
+        copied_request.context["listener_runner"] = self
+        if request.context.get_thread_context is not None:
+            copied_request.context["get_thread_context"] = request.context.get_thread_context
+        if request.context.save_thread_context is not None:
+            copied_request.context["save_thread_context"] = request.context.save_thread_context
+        return copied_request
+
+    def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None:
+        millis = int((time.time() - starting_time) * 1000)
+        self.logger.debug(debug_responding(response.status, response.body, millis))
+
+
+

Class variables

+
+
var lazy_listener_runnerLazyListenerRunner
+
+

The type of the None singleton.

+
+
var listener_completion_handlerListenerCompletionHandler
+
+

The type of the None singleton.

+
+
var listener_error_handlerListenerErrorHandler
+
+

The type of the None singleton.

+
+
var listener_executor : concurrent.futures._base.Executor
+
+

The type of the None singleton.

+
+
var listener_start_handlerListenerStartHandler
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var process_before_response : bool
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def run(self,
request: BoltRequest,
response: BoltResponse,
listener_name: str,
listener: Listener,
starting_time: float | None = None) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
def run(
+    self,
+    request: BoltRequest,
+    response: BoltResponse,
+    listener_name: str,
+    listener: Listener,
+    starting_time: Optional[float] = None,
+) -> Optional[BoltResponse]:
+    ack = request.context.ack
+    starting_time = starting_time if starting_time is not None else time.time()
+    if self.process_before_response:
+        if not request.lazy_only:
+            try:
+                self.listener_start_handler.handle(
+                    request=request,
+                    response=response,
+                )
+                returned_value = listener.run_ack_function(request=request, response=response)
+                if isinstance(returned_value, BoltResponse):
+                    response = returned_value
+                if ack.response is None and listener.auto_acknowledgement:
+                    ack()  # automatic ack() call if the call is not yet done
+            except Exception as e:
+                # The default response status code is 500 in this case.
+                # You can customize this by passing your own error handler.
+                if response is None:
+                    response = BoltResponse(status=500)
+                response.status = 500
+                self.listener_error_handler.handle(
+                    error=e,
+                    request=request,
+                    response=response,
+                )
+                ack.response = response
+            finally:
+                self.listener_completion_handler.handle(
+                    request=request,
+                    response=response,
+                )
+
+        for lazy_func in listener.lazy_functions:
+            if request.lazy_function_name:
+                func_name = get_name_for_callable(lazy_func)
+                if func_name == request.lazy_function_name:
+                    self.lazy_listener_runner.run(function=lazy_func, request=request)
+                    # This HTTP response won't be sent to Slack API servers.
+                    return BoltResponse(status=200)
+                else:
+                    continue
+            else:
+                self._start_lazy_function(lazy_func, request)
+
+        if response is not None:
+            self._debug_log_completion(starting_time, response)
+            return response
+        elif ack.response is not None:
+            self._debug_log_completion(starting_time, ack.response)
+            return ack.response
+    else:
+        if listener.auto_acknowledgement:
+            # acknowledge immediately in case of Events API
+            ack()
+
+        if not request.lazy_only:
+            # start the listener function asynchronously
+            def run_ack_function_asynchronously():
+                nonlocal response
+                try:
+                    self.listener_start_handler.handle(
+                        request=request,
+                        response=response,
+                    )
+                    listener.run_ack_function(request=request, response=response)
+                except Exception as e:
+                    # The default response status code is 500 in this case.
+                    # You can customize this by passing your own error handler.
+                    if listener.auto_acknowledgement:
+                        self.listener_error_handler.handle(
+                            error=e,
+                            request=request,
+                            response=response,
+                        )
+                    else:
+                        if response is None:
+                            response = BoltResponse(status=500)
+                        response.status = 500
+                        if ack.response is not None:  # already acknowledged
+                            response = None
+                        self.listener_error_handler.handle(
+                            error=e,
+                            request=request,
+                            response=response,
+                        )
+                        ack.response = response
+                finally:
+                    self.listener_completion_handler.handle(
+                        request=request,
+                        response=response,
+                    )
+
+            self.listener_executor.submit(run_ack_function_asynchronously)
+
+        for lazy_func in listener.lazy_functions:
+            if request.lazy_function_name:
+                func_name = get_name_for_callable(lazy_func)
+                if func_name == request.lazy_function_name:
+                    self.lazy_listener_runner.run(function=lazy_func, request=request)
+                    # This HTTP response won't be sent to Slack API servers.
+                    return BoltResponse(status=200)
+                else:
+                    continue
+            else:
+                self._start_lazy_function(lazy_func, request)
+
+        # await for the completion of ack() in the async listener execution
+        while ack.response is None and time.time() - starting_time <= listener.ack_timeout:
+            time.sleep(0.01)
+
+        if response is None and ack.response is None:
+            self.logger.warning(warning_did_not_call_ack(listener_name))
+            return None
+
+        if response is None and ack.response is not None:
+            response = ack.response
+            self._debug_log_completion(starting_time, response)
+            return response
+
+        if response is not None:
+            return response
+
+    # None for both means no ack() in the listener
+    return None
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener_matcher/async_builtins.html b/docs/reference/listener_matcher/async_builtins.html new file mode 100644 index 000000000..0df1215de --- /dev/null +++ b/docs/reference/listener_matcher/async_builtins.html @@ -0,0 +1,118 @@ + + + + + + +slack_bolt.listener_matcher.async_builtins API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener_matcher.async_builtins

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncBuiltinListenerMatcher +(*,
func: Callable[..., bool | Awaitable[bool]],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncBuiltinListenerMatcher(BuiltinListenerMatcher, AsyncListenerMatcher):
+    async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
+        return await self.func(  # type: ignore[misc]
+            **build_async_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/listener_matcher/async_listener_matcher.html b/docs/reference/listener_matcher/async_listener_matcher.html new file mode 100644 index 000000000..1366da4e2 --- /dev/null +++ b/docs/reference/listener_matcher/async_listener_matcher.html @@ -0,0 +1,317 @@ + + + + + + +slack_bolt.listener_matcher.async_listener_matcher API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener_matcher.async_listener_matcher

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncCustomListenerMatcher +(*,
app_name: str,
func: Callable[..., Awaitable[bool]],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncCustomListenerMatcher(AsyncListenerMatcher):
+    app_name: str
+    func: Callable[..., Awaitable[bool]]
+    arg_names: Sequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]], base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
+        return await self.func(
+            **build_async_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,  # type: ignore[arg-type]
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : Sequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., Awaitable[bool]]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+async def async_matches(self,
req: AsyncBoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
+    return await self.func(
+        **build_async_required_kwargs(
+            logger=self.logger,
+            required_arg_names=self.arg_names,  # type: ignore[arg-type]
+            request=req,
+            response=resp,
+            this_func=self.func,
+        )
+    )
+
+

Matches against the request and returns True if matched.

+

Args

+
+
req
+
The request
+
resp
+
The response
+
+

Returns

+

True if matched

+
+
+
+
+class cls +(*,
app_name: str,
func: Callable[..., Awaitable[bool]],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncCustomListenerMatcher(AsyncListenerMatcher):
+    app_name: str
+    func: Callable[..., Awaitable[bool]]
+    arg_names: Sequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]], base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
+        return await self.func(
+            **build_async_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,  # type: ignore[arg-type]
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : Sequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., Awaitable[bool]]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class AsyncListenerMatcher +
+
+
+ +Expand source code + +
class AsyncListenerMatcher(metaclass=ABCMeta):
+    @abstractmethod
+    async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
+        """Matches against the request and returns True if matched.
+
+        Args:
+            req: The request
+            resp: The response
+
+        Returns:
+            True if matched
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+async def async_matches(self,
req: AsyncBoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
@abstractmethod
+async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool:
+    """Matches against the request and returns True if matched.
+
+    Args:
+        req: The request
+        resp: The response
+
+    Returns:
+        True if matched
+    """
+    raise NotImplementedError()
+
+

Matches against the request and returns True if matched.

+

Args

+
+
req
+
The request
+
resp
+
The response
+
+

Returns

+

True if matched

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener_matcher/builtins.html b/docs/reference/listener_matcher/builtins.html new file mode 100644 index 000000000..a5aff3d0b --- /dev/null +++ b/docs/reference/listener_matcher/builtins.html @@ -0,0 +1,698 @@ + + + + + + +slack_bolt.listener_matcher.builtins API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener_matcher.builtins

+
+
+
+
+
+
+
+
+

Functions

+
+
+def action(constraints: str | re.Pattern | Dict[str, str | re.Pattern],
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def action(
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    if isinstance(constraints, (str, Pattern)):
+
+        def func(body: Dict[str, Any]) -> bool:
+            return (
+                _block_action(constraints, body)
+                or _attachment_action(constraints, body)
+                or _dialog_submission(constraints, body)
+                or _dialog_cancellation(constraints, body)
+                or _workflow_step_edit(constraints, body)
+            )
+
+        return build_listener_matcher(func, asyncio, base_logger)
+
+    elif "type" in constraints:
+        action_type = constraints["type"]
+        if action_type == "block_actions":
+            return block_action(constraints, asyncio)
+        if action_type == "interactive_message":
+            return attachment_action(constraints["callback_id"], asyncio)
+        if action_type == "dialog_submission":
+            return dialog_submission(constraints["callback_id"], asyncio)
+        if action_type == "dialog_cancellation":
+            return dialog_cancellation(constraints["callback_id"], asyncio)
+        # https://docs.slack.dev/legacy/legacy-steps-from-apps/
+        if action_type == "workflow_step_edit":
+            return workflow_step_edit(constraints["callback_id"], asyncio)
+
+        raise BoltError(f"type: {action_type} is unsupported")
+    elif "action_id" in constraints or "block_id" in constraints:
+        # The default value is "block_actions"
+        return block_action(constraints, asyncio)
+
+    raise BoltError(f"action ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict")
+
+
+
+
+def attachment_action(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def attachment_action(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return _attachment_action(callback_id, body)
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def block_action(constraints: str | re.Pattern | Dict[str, str | re.Pattern],
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def block_action(
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return _block_action(constraints, body)
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def block_suggestion(action_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def block_suggestion(
+    action_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return _block_suggestion(action_id, body)
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def build_listener_matcher(func: Callable[..., bool],
asyncio: bool,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def build_listener_matcher(
+    func: Callable[..., bool],
+    asyncio: bool,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    if asyncio:
+        from .async_builtins import AsyncBuiltinListenerMatcher
+
+        async def async_fun(body: Dict[str, Any]) -> bool:
+            return func(body)
+
+        return AsyncBuiltinListenerMatcher(func=async_fun, base_logger=base_logger)
+    else:
+        return BuiltinListenerMatcher(func=func, base_logger=base_logger)
+
+
+
+
+def command(command: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def command(
+    command: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return is_slash_command(body) and _matches(command, body["command"])
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def dialog_cancellation(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def dialog_cancellation(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return _dialog_cancellation(callback_id, body)
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def dialog_submission(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def dialog_submission(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return _dialog_submission(callback_id, body)
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def dialog_suggestion(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def dialog_suggestion(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return _dialog_suggestion(callback_id, body)
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def event(constraints: str | re.Pattern | Dict[str, str | Sequence[str | re.Pattern | None] | None],
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def event(
+    constraints: Union[
+        str,
+        Pattern,
+        Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+    ],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    if isinstance(constraints, (str, Pattern)):
+        event_type: Union[str, Pattern] = constraints
+        _verify_message_event_type(event_type)
+
+        def func(body: Dict[str, Any]) -> bool:
+            return is_event(body) and _matches(event_type, body["event"]["type"])
+
+        return build_listener_matcher(func, asyncio, base_logger)
+
+    elif "type" in constraints:
+        _verify_message_event_type(constraints["type"])  # type: ignore[arg-type]
+
+        def func(body: Dict[str, Any]) -> bool:
+            if is_event(body):
+                return _check_event_subtype(
+                    event_payload=body["event"],
+                    constraints=constraints,
+                )
+            return False
+
+        return build_listener_matcher(func, asyncio, base_logger)
+
+    raise BoltError(f"event ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict")
+
+
+
+
+def function_executed(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def function_executed(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return is_function(body) and _matches(callback_id, body.get("event", {}).get("function", {}).get("callback_id", ""))
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def global_shortcut(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def global_shortcut(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return is_global_shortcut(body) and _matches(callback_id, body["callback_id"])
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def message_event(constraints: Dict[str, str | Sequence[str | re.Pattern | None] | None],
keyword: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def message_event(
+    constraints: Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]],
+    keyword: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    if "type" in constraints and keyword is not None:
+        _verify_message_event_type(constraints["type"])  # type: ignore[arg-type]
+
+        def func(body: Dict[str, Any]) -> bool:
+            if is_event(body):
+                is_valid_subtype = _check_event_subtype(
+                    event_payload=body["event"],
+                    constraints=constraints,
+                )
+                if is_valid_subtype is True:
+                    # Check keyword matching
+                    text = body.get("event", {}).get("text", "")
+                    match_result = re.findall(keyword, text)
+                    if match_result is not None and match_result != []:
+                        return True
+            return False
+
+        return build_listener_matcher(func, asyncio, base_logger)
+
+    raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict")
+
+
+
+
+def message_shortcut(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def message_shortcut(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return is_message_shortcut(body) and _matches(callback_id, body["callback_id"])
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def options(constraints: str | re.Pattern | Dict[str, str | re.Pattern],
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def options(
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    if isinstance(constraints, (str, Pattern)):
+
+        def func(body: Dict[str, Any]) -> bool:
+            return _block_suggestion(constraints, body) or _dialog_suggestion(constraints, body)
+
+        return build_listener_matcher(func, asyncio, base_logger)
+
+    if "action_id" in constraints:
+        return block_suggestion(constraints["action_id"], asyncio)
+    if "callback_id" in constraints:
+        return dialog_suggestion(constraints["callback_id"], asyncio)
+    else:
+        raise BoltError(f"options ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict")
+
+
+
+
+def shortcut(constraints: str | re.Pattern | Dict[str, str | re.Pattern],
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def shortcut(
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    if isinstance(constraints, (str, Pattern)):
+        callback_id: Union[str, Pattern] = constraints
+
+        def func(body: Dict[str, Any]) -> bool:
+            return is_shortcut(body) and _matches(callback_id, body["callback_id"])
+
+        return build_listener_matcher(func, asyncio, base_logger)
+
+    elif "type" in constraints and "callback_id" in constraints:
+        if constraints["type"] == "shortcut":
+            return global_shortcut(constraints["callback_id"], asyncio)
+        if constraints["type"] == "message_action":
+            return message_shortcut(constraints["callback_id"], asyncio)
+
+    raise BoltError(f"shortcut ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict")
+
+
+
+
+def view(constraints: str | re.Pattern | Dict[str, str | re.Pattern],
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def view(
+    constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    if isinstance(constraints, (str, Pattern)):
+        return view_submission(constraints, asyncio)
+    elif "type" in constraints:
+        if constraints["type"] == "view_submission":
+            return view_submission(constraints["callback_id"], asyncio)
+        if constraints["type"] == "view_closed":
+            return view_closed(constraints["callback_id"], asyncio)
+
+    raise BoltError(f"view ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict")
+
+
+
+
+def view_closed(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def view_closed(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return is_view_closed(body) and _matches(callback_id, body["view"]["callback_id"])
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def view_submission(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def view_submission(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return is_view_submission(body) and _matches(callback_id, body["view"]["callback_id"])
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def workflow_step_edit(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def workflow_step_edit(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return _workflow_step_edit(callback_id, body)
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def workflow_step_execute(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def workflow_step_execute(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return (
+            is_event(body)
+            and _matches("workflow_step_execute", body["event"]["type"])
+            and "workflow_step" in body["event"]
+            and _matches(callback_id, body["event"]["callback_id"])
+        )
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+def workflow_step_save(callback_id: str | re.Pattern,
asyncio: bool = False,
base_logger: logging.Logger | None = None) ‑> ListenerMatcher | AsyncListenerMatcher
+
+
+
+ +Expand source code + +
def workflow_step_save(
+    callback_id: Union[str, Pattern],
+    asyncio: bool = False,
+    base_logger: Optional[Logger] = None,
+) -> Union[ListenerMatcher, "AsyncListenerMatcher"]:  # type: ignore[name-defined]
+    def func(body: Dict[str, Any]) -> bool:
+        return is_workflow_step_save(body) and _matches(callback_id, body["view"]["callback_id"])
+
+    return build_listener_matcher(func, asyncio, base_logger)
+
+
+
+
+
+
+

Classes

+
+
+class BuiltinListenerMatcher +(*,
func: Callable[..., bool | Awaitable[bool]],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class BuiltinListenerMatcher(ListenerMatcher):
+    def __init__(
+        self,
+        *,
+        func: Callable[..., Union[bool, Awaitable[bool]]],
+        base_logger: Optional[Logger] = None,
+    ):
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_logger(self.func, base_logger)
+
+    def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+        return self.func(  # type: ignore[return-value]
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/listener_matcher/custom_listener_matcher.html b/docs/reference/listener_matcher/custom_listener_matcher.html new file mode 100644 index 000000000..087d36907 --- /dev/null +++ b/docs/reference/listener_matcher/custom_listener_matcher.html @@ -0,0 +1,147 @@ + + + + + + +slack_bolt.listener_matcher.custom_listener_matcher API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener_matcher.custom_listener_matcher

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomListenerMatcher +(*,
app_name: str,
func: Callable[..., bool],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class CustomListenerMatcher(ListenerMatcher):
+    app_name: str
+    func: Callable[..., bool]
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable[..., bool], base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+        return self.func(
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., bool]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/listener_matcher/index.html b/docs/reference/listener_matcher/index.html new file mode 100644 index 000000000..a93c86d98 --- /dev/null +++ b/docs/reference/listener_matcher/index.html @@ -0,0 +1,253 @@ + + + + + + +slack_bolt.listener_matcher API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener_matcher

+
+
+

A listener matcher is a simplified version of listener middleware. +A listener matcher function returns bool value instead of next() method invocation inside. +This interface enables developers to utilize simple predicate functions for additional listener conditions.

+
+
+

Sub-modules

+
+
slack_bolt.listener_matcher.async_builtins
+
+
+
+
slack_bolt.listener_matcher.async_listener_matcher
+
+
+
+
slack_bolt.listener_matcher.builtins
+
+
+
+
slack_bolt.listener_matcher.custom_listener_matcher
+
+
+
+
slack_bolt.listener_matcher.listener_matcher
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomListenerMatcher +(*,
app_name: str,
func: Callable[..., bool],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class CustomListenerMatcher(ListenerMatcher):
+    app_name: str
+    func: Callable[..., bool]
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable[..., bool], base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+        return self.func(
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                this_func=self.func,
+            )
+        )
+
+
+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., bool]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class ListenerMatcher +
+
+
+ +Expand source code + +
class ListenerMatcher(metaclass=ABCMeta):
+    @abstractmethod
+    def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+        """Matches against the request and returns True if matched.
+
+        Args:
+            req: The request
+            resp: The response
+
+        Returns:
+            True if matched.
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+def matches(self,
req: BoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
@abstractmethod
+def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+    """Matches against the request and returns True if matched.
+
+    Args:
+        req: The request
+        resp: The response
+
+    Returns:
+        True if matched.
+    """
+    raise NotImplementedError()
+
+

Matches against the request and returns True if matched.

+

Args

+
+
req
+
The request
+
resp
+
The response
+
+

Returns

+

True if matched.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/listener_matcher/listener_matcher.html b/docs/reference/listener_matcher/listener_matcher.html new file mode 100644 index 000000000..0618f7e4e --- /dev/null +++ b/docs/reference/listener_matcher/listener_matcher.html @@ -0,0 +1,143 @@ + + + + + + +slack_bolt.listener_matcher.listener_matcher API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.listener_matcher.listener_matcher

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class ListenerMatcher +
+
+
+ +Expand source code + +
class ListenerMatcher(metaclass=ABCMeta):
+    @abstractmethod
+    def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+        """Matches against the request and returns True if matched.
+
+        Args:
+            req: The request
+            resp: The response
+
+        Returns:
+            True if matched.
+        """
+        raise NotImplementedError()
+
+
+

Subclasses

+ +

Methods

+
+
+def matches(self,
req: BoltRequest,
resp: BoltResponse) ‑> bool
+
+
+
+ +Expand source code + +
@abstractmethod
+def matches(self, req: BoltRequest, resp: BoltResponse) -> bool:
+    """Matches against the request and returns True if matched.
+
+    Args:
+        req: The request
+        resp: The response
+
+    Returns:
+        True if matched.
+    """
+    raise NotImplementedError()
+
+

Matches against the request and returns True if matched.

+

Args

+
+
req
+
The request
+
resp
+
The response
+
+

Returns

+

True if matched.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/logger/index.html b/docs/reference/logger/index.html new file mode 100644 index 000000000..d0b2ef33f --- /dev/null +++ b/docs/reference/logger/index.html @@ -0,0 +1,127 @@ + + + + + + +slack_bolt.logger API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.logger

+
+
+

Bolt for Python relies on the standard logging module.

+
+
+

Sub-modules

+
+
slack_bolt.logger.messages
+
+
+
+
+
+
+
+
+

Functions

+
+
+def get_bolt_app_logger(app_name: str, cls: object = None, base_logger: logging.Logger | None = None) ‑> logging.Logger +
+
+
+ +Expand source code + +
def get_bolt_app_logger(app_name: str, cls: object = None, base_logger: Optional[Logger] = None) -> Logger:
+    logger: Logger = (
+        logging.getLogger(f"{app_name}:{cls.__name__}") if cls and hasattr(cls, "__name__") else logging.getLogger(app_name)
+    )
+
+    if base_logger is not None:
+        _configure_from_base_logger(logger, base_logger)
+    else:
+        _configure_from_root(logger)
+    return logger
+
+
+
+
+def get_bolt_logger(cls: Any, base_logger: logging.Logger | None = None) ‑> logging.Logger +
+
+
+ +Expand source code + +
def get_bolt_logger(cls: Any, base_logger: Optional[Logger] = None) -> Logger:
+    logger = logging.getLogger(f"slack_bolt.{cls.__name__}")
+    if base_logger is not None:
+        _configure_from_base_logger(logger, base_logger)
+    else:
+        _configure_from_root(logger)
+    return logger
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/logger/messages.html b/docs/reference/logger/messages.html new file mode 100644 index 000000000..e69b45fc9 --- /dev/null +++ b/docs/reference/logger/messages.html @@ -0,0 +1,626 @@ + + + + + + +slack_bolt.logger.messages API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.logger.messages

+
+
+
+
+
+
+
+
+

Functions

+
+
+def debug_applying_middleware(middleware_name: str) ‑> str +
+
+
+ +Expand source code + +
def debug_applying_middleware(middleware_name: str) -> str:
+    return f"Applying {middleware_name}"
+
+
+
+
+def debug_checking_listener(listener_name: str) ‑> str +
+
+
+ +Expand source code + +
def debug_checking_listener(listener_name: str) -> str:
+    return f"Checking listener: {listener_name} ..."
+
+
+
+
+def debug_responding(status: int, body: str, millis: int) ‑> str +
+
+
+ +Expand source code + +
def debug_responding(status: int, body: str, millis: int) -> str:
+    return f'Responding with status: {status} body: "{body}" ({millis} millis)'
+
+
+
+
+def debug_return_listener_middleware_response(listener_name: str, status: int, body: str, starting_time: float) ‑> str +
+
+
+ +Expand source code + +
def debug_return_listener_middleware_response(listener_name: str, status: int, body: str, starting_time: float) -> str:
+    millis = int((time.time() - starting_time) * 1000)
+    return (
+        "Responding with listener middleware's response - "
+        f"listener: {listener_name}, status: {status}, body: {body} ({millis} millis)"
+    )
+
+
+
+
+def debug_running_lazy_listener(func_name: str) ‑> str +
+
+
+ +Expand source code + +
def debug_running_lazy_listener(func_name: str) -> str:
+    return f"Running lazy listener: {func_name} ..."
+
+
+
+
+def debug_running_listener(listener_name: str) ‑> str +
+
+
+ +Expand source code + +
def debug_running_listener(listener_name: str) -> str:
+    return f"Running listener: {listener_name} ..."
+
+
+
+
+def error_auth_test_failure(error_response: slack_sdk.web.slack_response.SlackResponse) ‑> str +
+
+
+ +Expand source code + +
def error_auth_test_failure(error_response: SlackResponse) -> str:
+    return f"`token` is invalid (auth.test result: {error_response})"
+
+
+
+
+def error_authorize_conflicts() ‑> str +
+
+
+ +Expand source code + +
def error_authorize_conflicts() -> str:
+    return "`authorize` in the top-level arguments is not allowed when you pass either `oauth_settings` or `oauth_flow`"
+
+
+
+
+def error_client_invalid_type() ‑> str +
+
+
+ +Expand source code + +
def error_client_invalid_type() -> str:
+    return "`client` must be a slack_sdk.web.WebClient"
+
+
+
+
+def error_client_invalid_type_async() ‑> str +
+
+
+ +Expand source code + +
def error_client_invalid_type_async() -> str:
+    return "`client` must be a slack_sdk.web.async_client.AsyncWebClient"
+
+
+
+
+def error_installation_store_required_for_builtin_listeners() ‑> str +
+
+
+ +Expand source code + +
def error_installation_store_required_for_builtin_listeners() -> str:
+    return (
+        "To use the event listeners for token revocation handling, "
+        "setting a valid `installation_store` to `App`/`AsyncApp` is required."
+    )
+
+
+
+
+def error_listener_function_must_be_coro_func(func_name: str) ‑> str +
+
+
+ +Expand source code + +
def error_listener_function_must_be_coro_func(func_name: str) -> str:
+    return f"The listener function ({func_name}) is not a coroutine function."
+
+
+
+
+def error_message_event_type(event_type: str | re.Pattern) ‑> str +
+
+
+ +Expand source code + +
def error_message_event_type(event_type: Union[str, Pattern]) -> str:
+    return (
+        f'Although the document mentions "{event_type}", '
+        'it is not a valid event type. Use "message" instead. '
+        "If you want to filter message events, you can use `event.channel_type` for it."
+    )
+
+
+
+
+def error_oauth_flow_invalid_type_async() ‑> str +
+
+
+ +Expand source code + +
def error_oauth_flow_invalid_type_async() -> str:
+    return "`oauth_flow` must be a slack_bolt.oauth.async_oauth_flow.AsyncOAuthFlow"
+
+
+
+
+def error_oauth_flow_or_authorize_required() ‑> str +
+
+
+ +Expand source code + +
def error_oauth_flow_or_authorize_required() -> str:
+    return "`oauth_flow` or `authorize` must be configured to make a Bolt app"
+
+
+
+
+def error_oauth_settings_invalid_type_async() ‑> str +
+
+
+ +Expand source code + +
def error_oauth_settings_invalid_type_async() -> str:
+    return "`oauth_settings` must be a slack_bolt.oauth.async_oauth_settings.AsyncOAuthSettings"
+
+
+
+
+def error_token_required() ‑> str +
+
+
+ +Expand source code + +
def error_token_required() -> str:
+    return "Either an env variable `SLACK_BOT_TOKEN` " "or `token` argument in the constructor is required."
+
+
+
+
+def error_unexpected_listener_middleware(middleware_type) ‑> str +
+
+
+ +Expand source code + +
def error_unexpected_listener_middleware(middleware_type) -> str:
+    return f"Unexpected value for a listener middleware: {middleware_type}"
+
+
+
+
+def info_default_oauth_settings_loaded() ‑> str +
+
+
+ +Expand source code + +
def info_default_oauth_settings_loaded() -> str:
+    return (
+        "As you've set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET env variables, "
+        "Bolt has enabled the file-based InstallationStore/OAuthStateStore for you. "
+        "Note that these file-based stores are for local development. "
+        "If you'd like to use a different data store, set the oauth_settings argument in the App constructor. "
+        "Please refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for more details."
+    )
+
+
+
+
+def warning_ack_timeout_has_no_effect(identifier: str | re.Pattern, ack_timeout: int) ‑> str +
+
+
+ +Expand source code + +
def warning_ack_timeout_has_no_effect(identifier: Union[str, Pattern], ack_timeout: int) -> str:
+    handler_example = f'@app.function("{identifier}")' if isinstance(identifier, str) else f"@app.function({identifier})"
+    return f"On {handler_example}, as `auto_acknowledge` is `True`, " f"`ack_timeout={ack_timeout}` you gave will be unused"
+
+
+
+
+def warning_bot_only_conflicts() ‑> str +
+
+
+ +Expand source code + +
def warning_bot_only_conflicts() -> str:
+    return (
+        "installation_store_bot_only exists in both App and OAuthFlow.settings. "
+        "The one passed in App constructor is used."
+    )
+
+
+
+
+def warning_client_prioritized_and_token_skipped() ‑> str +
+
+
+ +Expand source code + +
def warning_client_prioritized_and_token_skipped() -> str:
+    return "As you gave `client` as well, `token` will be unused."
+
+
+
+
+def warning_did_not_call_ack(listener_name: str) ‑> str +
+
+
+ +Expand source code + +
def warning_did_not_call_ack(listener_name: str) -> str:
+    return f"{listener_name} didn't call ack()"
+
+
+
+
+def warning_installation_store_conflicts() ‑> str +
+
+
+ +Expand source code + +
def warning_installation_store_conflicts() -> str:
+    return "As you gave both `installation_store` and `oauth_settings`/`auth_flow`, the top level one is unused."
+
+
+
+
+def warning_skip_uncommon_arg_name(arg_name: str) ‑> str +
+
+
+ +Expand source code + +
def warning_skip_uncommon_arg_name(arg_name: str) -> str:
+    return (
+        f"Bolt skips injecting a value to the first keyword argument ({arg_name}). "
+        "If it is self/cls of a method, we recommend using the common names."
+    )
+
+
+
+
+def warning_token_skipped() ‑> str +
+
+
+ +Expand source code + +
def warning_token_skipped() -> str:
+    return (
+        "As `installation_store` or `authorize` has been used, " "`token` (or SLACK_BOT_TOKEN env variable) will be ignored."
+    )
+
+
+
+
+def warning_unhandled_by_global_middleware(name: str,
req: BoltRequest | AsyncBoltRequest) ‑> str
+
+
+
+ +Expand source code + +
def warning_unhandled_by_global_middleware(
+    name: str, req: Union[BoltRequest, "AsyncBoltRequest"]  # type: ignore[name-defined]
+) -> str:
+    return (
+        f"A global middleware ({name}) skipped calling either `next()` or `next_()` "
+        f"without providing a response for the request ({req.body})"
+    )
+
+
+
+
+def warning_unhandled_request(req: BoltRequest | AsyncBoltRequest) ‑> str +
+
+
+ +Expand source code + +
def warning_unhandled_request(
+    req: Union[BoltRequest, "AsyncBoltRequest"],  # type: ignore[name-defined]
+) -> str:
+    filtered_body = _build_filtered_body(req.body)
+    default_message = f"Unhandled request ({filtered_body})"
+    is_async = not isinstance(req, BoltRequest)
+    if is_workflow_step_edit(req.body) or is_workflow_step_save(req.body) or is_workflow_step_execute(req.body):
+        # @app.step
+        callback_id = (
+            filtered_body.get("callback_id") or filtered_body.get("view", {}).get("callback_id") or "your-callback-id"
+        )
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+from slack_bolt.workflows.step{'.async_step' if is_async else ''} import {'Async' if is_async else ''}WorkflowStep
+ws = {'Async' if is_async else ''}WorkflowStep(
+    callback_id="{callback_id}",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+# Pass Step to set up listeners
+app.step(ws)
+""",
+        )
+    if is_action(req.body):
+        # @app.action
+        action_id_or_callback_id = req.body.get("callback_id")
+        if req.body.get("type") == "block_actions":
+            action_id_or_callback_id = req.body["actions"][0].get("action_id")
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+@app.action("{action_id_or_callback_id}")
+{'async ' if is_async else ''}def handle_some_action(ack, body, logger):
+    {'await ' if is_async else ''}ack()
+    logger.info(body)
+""",
+        )
+    if is_options(req.body):
+        # @app.options
+        constraints = '"action-id"'
+        if req.body.get("action_id") is not None:
+            constraints = '"' + req.body["action_id"] + '"'
+        elif req.body.get("type") == "dialog_suggestion":
+            constraints = f"""{{"type": "dialog_suggestion", "callback_id": "{req.body.get('callback_id')}"}}"""
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+@app.options({constraints})
+{'async ' if is_async else ''}def handle_some_options(ack):
+    {'await ' if is_async else ''}ack(options=[ ... ])
+""",
+        )
+    if is_shortcut(req.body):
+        # @app.shortcut
+        id = req.body.get("action_id") or req.body.get("callback_id")
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+@app.shortcut("{id}")
+{'async ' if is_async else ''}def handle_shortcuts(ack, body, logger):
+    {'await ' if is_async else ''}ack()
+    logger.info(body)
+""",
+        )
+    if is_view_submission(req.body):
+        # @app.view
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+@app.view("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}")
+{'async ' if is_async else ''}def handle_view_submission_events(ack, body, logger):
+    {'await ' if is_async else ''}ack()
+    logger.info(body)
+""",
+        )
+    if is_view_closed(req.body):
+        # @app.view
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+@app.view_closed("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}")
+{'async ' if is_async else ''}def handle_view_closed_events(ack, body, logger):
+    {'await ' if is_async else ''}ack()
+    logger.info(body)
+""",
+        )
+    if is_event(req.body):
+        # @app.event
+        event = req.body.get("event", {})
+        event_type = event.get("type")
+        if is_function(req.body):
+            # @app.function
+            callback_id = event.get("function", {}).get("callback_id", "function_id")
+            return _build_unhandled_request_suggestion(
+                default_message,
+                f"""
+@app.function("{callback_id}")
+{'async ' if is_async else ''}def handle_some_function(ack, body, complete, fail, logger):
+    {'await ' if is_async else ''}ack()
+    logger.info(body)
+    try:
+        # TODO: do something here
+        outputs = {{}}
+        {'await ' if is_async else ''}complete(outputs=outputs)
+    except Exception as e:
+        error = f"Failed to handle a function request (error: {{e}})"
+        {'await ' if is_async else ''}fail(error=error)
+""",
+            )
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+@app.event("{event_type}")
+{'async ' if is_async else ''}def handle_{event_type}_events(body, logger):
+    logger.info(body)
+""",
+        )
+    if is_slash_command(req.body):
+        # @app.command
+        command = req.body.get("command", "/your-command")
+        return _build_unhandled_request_suggestion(
+            default_message,
+            f"""
+@app.command("{command}")
+{'async ' if is_async else ''}def handle_some_command(ack, body, logger):
+    {'await ' if is_async else ''}ack()
+    logger.info(body)
+""",
+        )
+    return default_message
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/assistant/assistant.html b/docs/reference/middleware/assistant/assistant.html new file mode 100644 index 000000000..946416d62 --- /dev/null +++ b/docs/reference/middleware/assistant/assistant.html @@ -0,0 +1,664 @@ + + + + + + +slack_bolt.middleware.assistant.assistant API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.assistant.assistant

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Assistant +(*,
app_name: str = 'assistant',
thread_context_store: AssistantThreadContextStore | None = None,
logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class Assistant(Middleware):
+    _thread_started_listeners: Optional[List[Listener]]
+    _thread_context_changed_listeners: Optional[List[Listener]]
+    _user_message_listeners: Optional[List[Listener]]
+    _bot_message_listeners: Optional[List[Listener]]
+
+    thread_context_store: Optional[AssistantThreadContextStore]
+    base_logger: Optional[logging.Logger]
+
+    def __init__(
+        self,
+        *,
+        app_name: str = "assistant",
+        thread_context_store: Optional[AssistantThreadContextStore] = None,
+        logger: Optional[logging.Logger] = None,
+    ):
+        self.app_name = app_name
+        self.thread_context_store = thread_context_store
+        self.base_logger = logger
+
+        self._thread_started_listeners = None
+        self._thread_context_changed_listeners = None
+        self._user_message_listeners = None
+        self._bot_message_listeners = None
+
+    def thread_started(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_started_listeners is None:
+            self._thread_started_listeners = []
+        all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def user_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._user_message_listeners is None:
+            self._user_message_listeners = []
+        all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def bot_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._bot_message_listeners is None:
+            self._bot_message_listeners = []
+        all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def thread_context_changed(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_context_changed_listeners is None:
+            self._thread_context_changed_listeners = []
+        all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def _merge_matchers(
+        self,
+        primary_matcher: Callable[..., bool],
+        custom_matchers: Optional[Union[Callable[..., bool], ListenerMatcher]],
+    ):
+        return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + (
+            custom_matchers or []
+        )  # 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]
+        self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]
+    ) -> Optional[BoltResponse]:
+        if self._thread_context_changed_listeners is None:
+            self.thread_context_changed(self.default_thread_context_changed)
+
+        listener_runner: ThreadListenerRunner = req.context.listener_runner
+        for listeners in [
+            self._thread_started_listeners,
+            self._thread_context_changed_listeners,
+            self._user_message_listeners,
+            self._bot_message_listeners,
+        ]:
+            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,
+                            listener_name="assistant_listener",
+                            listener=listener,
+                        )
+        if is_other_message_sub_event_in_assistant_thread(req.body):
+            # message_changed, message_deleted, etc.
+            return req.context.ack()
+
+        next()
+
+    def build_listener(
+        self,
+        listener_or_functions: Union[Listener, Callable, List[Callable]],
+        matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None,
+        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, 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)
+
+            matchers = matchers if matchers else []
+            listener_matchers: List[ListenerMatcher] = []
+            for matcher in matchers:
+                if isinstance(matcher, ListenerMatcher):
+                    listener_matchers.append(matcher)
+                elif isinstance(matcher, Callable):  # type: ignore[arg-type]
+                    listener_matchers.append(
+                        build_listener_matcher(
+                            func=matcher,
+                            asyncio=False,
+                            base_logger=base_logger,
+                        )
+                    )
+            return CustomListener(
+                app_name=self.app_name,
+                matchers=listener_matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=True,
+                base_logger=base_logger or self.base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+

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

+

Ancestors

+ +

Class variables

+
+
var base_logger : logging.Logger | None
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore | None
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def default_thread_context_changed(save_thread_context: SaveThreadContext,
payload: dict)
+
+
+
+ +Expand source code + +
@staticmethod
+def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict):
+    save_thread_context(payload["assistant_thread"]["context"])
+
+
+
+
+

Methods

+
+
+def bot_message(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def bot_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._bot_message_listeners is None:
+        self._bot_message_listeners = []
+    all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def build_listener(self,
listener_or_functions: Listener | Callable | List[Callable],
matchers: List[ListenerMatcher | Callable[..., bool]] | None = None,
middleware: List[Middleware] | None = None,
base_logger: logging.Logger | None = None) ‑> Listener
+
+
+
+ +Expand source code + +
def build_listener(
+    self,
+    listener_or_functions: Union[Listener, Callable, List[Callable]],
+    matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None,
+    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, 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)
+
+        matchers = matchers if matchers else []
+        listener_matchers: List[ListenerMatcher] = []
+        for matcher in matchers:
+            if isinstance(matcher, ListenerMatcher):
+                listener_matchers.append(matcher)
+            elif isinstance(matcher, Callable):  # type: ignore[arg-type]
+                listener_matchers.append(
+                    build_listener_matcher(
+                        func=matcher,
+                        asyncio=False,
+                        base_logger=base_logger,
+                    )
+                )
+        return CustomListener(
+            app_name=self.app_name,
+            matchers=listener_matchers,
+            middleware=middleware,
+            ack_function=ack_function,
+            lazy_functions=functions,
+            auto_acknowledgement=True,
+            base_logger=base_logger or self.base_logger,
+        )
+    else:
+        raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+
+
+
+def thread_context_changed(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_context_changed(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_context_changed_listeners is None:
+        self._thread_context_changed_listeners = []
+    all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def thread_started(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_started(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_started_listeners is None:
+        self._thread_started_listeners = []
+    all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def user_message(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def user_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._user_message_listeners is None:
+        self._user_message_listeners = []
+    all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/assistant/async_assistant.html b/docs/reference/middleware/assistant/async_assistant.html new file mode 100644 index 000000000..748be2cbf --- /dev/null +++ b/docs/reference/middleware/assistant/async_assistant.html @@ -0,0 +1,724 @@ + + + + + + +slack_bolt.middleware.assistant.async_assistant API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.assistant.async_assistant

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAssistant +(*,
app_name: str = 'assistant',
thread_context_store: AsyncAssistantThreadContextStore | None = None,
logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncAssistant(AsyncMiddleware):
+    _thread_started_listeners: Optional[List[AsyncListener]]
+    _user_message_listeners: Optional[List[AsyncListener]]
+    _bot_message_listeners: Optional[List[AsyncListener]]
+    _thread_context_changed_listeners: Optional[List[AsyncListener]]
+
+    thread_context_store: Optional[AsyncAssistantThreadContextStore]
+    base_logger: Optional[logging.Logger]
+
+    def __init__(
+        self,
+        *,
+        app_name: str = "assistant",
+        thread_context_store: Optional[AsyncAssistantThreadContextStore] = None,
+        logger: Optional[logging.Logger] = None,
+    ):
+        self.app_name = app_name
+        self.thread_context_store = thread_context_store
+        self.base_logger = logger
+
+        self._thread_started_listeners = None
+        self._thread_context_changed_listeners = None
+        self._user_message_listeners = None
+        self._bot_message_listeners = None
+
+    def thread_started(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_started_listeners is None:
+            self._thread_started_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_assistant_thread_started_event,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def user_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._user_message_listeners is None:
+            self._user_message_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_user_message_event_in_assistant_thread,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def bot_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._bot_message_listeners is None:
+            self._bot_message_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_bot_message_event_in_assistant_thread,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def thread_context_changed(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_context_changed_listeners is None:
+            self._thread_context_changed_listeners = []
+        all_matchers = self._merge_matchers(
+            build_listener_matcher(
+                func=is_assistant_thread_context_changed_event,
+                asyncio=True,
+                base_logger=self.base_logger,
+            ),  # type: ignore[arg-type]
+            matchers,
+        )
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    @staticmethod
+    def _merge_matchers(
+        primary_matcher: Union[Callable[..., bool], AsyncListenerMatcher],
+        custom_matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]],
+    ):
+        return [primary_matcher] + (custom_matchers or [])  # type: ignore[operator]
+
+    @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]
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> Optional[BoltResponse]:
+        if self._thread_context_changed_listeners is None:
+            self.thread_context_changed(self.default_thread_context_changed)
+
+        listener_runner: AsyncioListenerRunner = req.context.listener_runner
+        for listeners in [
+            self._thread_started_listeners,
+            self._thread_context_changed_listeners,
+            self._user_message_listeners,
+            self._bot_message_listeners,
+        ]:
+            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,
+                            listener_name="assistant_listener",
+                            listener=listener,
+                        )
+        if is_other_message_sub_event_in_assistant_thread(req.body):
+            # message_changed, message_deleted, etc.
+            return await req.context.ack()
+
+        await next()
+
+    def build_listener(
+        self,
+        listener_or_functions: Union[AsyncListener, Callable, List[Callable]],
+        matchers: Optional[List[Union[AsyncListenerMatcher, Callable[..., Awaitable[bool]]]]] = None,
+        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, 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)
+
+            matchers = matchers if matchers else []
+            listener_matchers: List[AsyncListenerMatcher] = []
+            for matcher in matchers:
+                if isinstance(matcher, AsyncListenerMatcher):
+                    listener_matchers.append(matcher)
+                else:
+                    listener_matchers.append(
+                        build_listener_matcher(
+                            func=matcher,  # type: ignore[arg-type]
+                            asyncio=True,
+                            base_logger=base_logger,
+                        )
+                    )
+            return AsyncCustomListener(
+                app_name=self.app_name,
+                matchers=listener_matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=True,
+                base_logger=base_logger or self.base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+

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

+

Ancestors

+ +

Class variables

+
+
var base_logger : logging.Logger | None
+
+

The type of the None singleton.

+
+
var thread_context_storeAsyncAssistantThreadContextStore | None
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+async def default_thread_context_changed(save_thread_context: AsyncSaveThreadContext,
payload: dict)
+
+
+
+ +Expand source code + +
@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)
+
+
+
+
+

Methods

+
+
+def bot_message(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def bot_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._bot_message_listeners is None:
+        self._bot_message_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_bot_message_event_in_assistant_thread,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def build_listener(self,
listener_or_functions: AsyncListener | Callable | List[Callable],
matchers: List[AsyncListenerMatcher | Callable[..., Awaitable[bool]]] | None = None,
middleware: List[AsyncMiddleware] | None = None,
base_logger: logging.Logger | None = None) ‑> AsyncListener
+
+
+
+ +Expand source code + +
def build_listener(
+    self,
+    listener_or_functions: Union[AsyncListener, Callable, List[Callable]],
+    matchers: Optional[List[Union[AsyncListenerMatcher, Callable[..., Awaitable[bool]]]]] = None,
+    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, 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)
+
+        matchers = matchers if matchers else []
+        listener_matchers: List[AsyncListenerMatcher] = []
+        for matcher in matchers:
+            if isinstance(matcher, AsyncListenerMatcher):
+                listener_matchers.append(matcher)
+            else:
+                listener_matchers.append(
+                    build_listener_matcher(
+                        func=matcher,  # type: ignore[arg-type]
+                        asyncio=True,
+                        base_logger=base_logger,
+                    )
+                )
+        return AsyncCustomListener(
+            app_name=self.app_name,
+            matchers=listener_matchers,
+            middleware=middleware,
+            ack_function=ack_function,
+            lazy_functions=functions,
+            auto_acknowledgement=True,
+            base_logger=base_logger or self.base_logger,
+        )
+    else:
+        raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+
+
+
+def thread_context_changed(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_context_changed(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_context_changed_listeners is None:
+        self._thread_context_changed_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_assistant_thread_context_changed_event,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def thread_started(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_started(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_started_listeners is None:
+        self._thread_started_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_assistant_thread_started_event,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def user_message(self,
*args,
matchers: Callable[..., bool] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def user_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._user_message_listeners is None:
+        self._user_message_listeners = []
+    all_matchers = self._merge_matchers(
+        build_listener_matcher(
+            func=is_user_message_event_in_assistant_thread,
+            asyncio=True,
+            base_logger=self.base_logger,
+        ),  # type: ignore[arg-type]
+        matchers,
+    )
+    if is_used_without_argument(args):
+        func = args[0]
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/assistant/index.html b/docs/reference/middleware/assistant/index.html new file mode 100644 index 000000000..e9fce8d64 --- /dev/null +++ b/docs/reference/middleware/assistant/index.html @@ -0,0 +1,681 @@ + + + + + + +slack_bolt.middleware.assistant API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.assistant

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.assistant.assistant
+
+
+
+
slack_bolt.middleware.assistant.async_assistant
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Assistant +(*,
app_name: str = 'assistant',
thread_context_store: AssistantThreadContextStore | None = None,
logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class Assistant(Middleware):
+    _thread_started_listeners: Optional[List[Listener]]
+    _thread_context_changed_listeners: Optional[List[Listener]]
+    _user_message_listeners: Optional[List[Listener]]
+    _bot_message_listeners: Optional[List[Listener]]
+
+    thread_context_store: Optional[AssistantThreadContextStore]
+    base_logger: Optional[logging.Logger]
+
+    def __init__(
+        self,
+        *,
+        app_name: str = "assistant",
+        thread_context_store: Optional[AssistantThreadContextStore] = None,
+        logger: Optional[logging.Logger] = None,
+    ):
+        self.app_name = app_name
+        self.thread_context_store = thread_context_store
+        self.base_logger = logger
+
+        self._thread_started_listeners = None
+        self._thread_context_changed_listeners = None
+        self._user_message_listeners = None
+        self._bot_message_listeners = None
+
+    def thread_started(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_started_listeners is None:
+            self._thread_started_listeners = []
+        all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_started_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def user_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._user_message_listeners is None:
+            self._user_message_listeners = []
+        all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._user_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def bot_message(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._bot_message_listeners is None:
+            self._bot_message_listeners = []
+        all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._bot_message_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def thread_context_changed(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        if self._thread_context_changed_listeners is None:
+            self._thread_context_changed_listeners = []
+        all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers)
+        if is_used_without_argument(args):
+            func = args[0]
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=func,
+                    matchers=all_matchers,
+                    middleware=middleware,  # type: ignore[arg-type]
+                )
+            )
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._thread_context_changed_listeners.append(
+                self.build_listener(
+                    listener_or_functions=functions,
+                    matchers=all_matchers,
+                    middleware=middleware,
+                )
+            )
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def _merge_matchers(
+        self,
+        primary_matcher: Callable[..., bool],
+        custom_matchers: Optional[Union[Callable[..., bool], ListenerMatcher]],
+    ):
+        return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + (
+            custom_matchers or []
+        )  # 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]
+        self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]
+    ) -> Optional[BoltResponse]:
+        if self._thread_context_changed_listeners is None:
+            self.thread_context_changed(self.default_thread_context_changed)
+
+        listener_runner: ThreadListenerRunner = req.context.listener_runner
+        for listeners in [
+            self._thread_started_listeners,
+            self._thread_context_changed_listeners,
+            self._user_message_listeners,
+            self._bot_message_listeners,
+        ]:
+            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,
+                            listener_name="assistant_listener",
+                            listener=listener,
+                        )
+        if is_other_message_sub_event_in_assistant_thread(req.body):
+            # message_changed, message_deleted, etc.
+            return req.context.ack()
+
+        next()
+
+    def build_listener(
+        self,
+        listener_or_functions: Union[Listener, Callable, List[Callable]],
+        matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None,
+        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, 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)
+
+            matchers = matchers if matchers else []
+            listener_matchers: List[ListenerMatcher] = []
+            for matcher in matchers:
+                if isinstance(matcher, ListenerMatcher):
+                    listener_matchers.append(matcher)
+                elif isinstance(matcher, Callable):  # type: ignore[arg-type]
+                    listener_matchers.append(
+                        build_listener_matcher(
+                            func=matcher,
+                            asyncio=False,
+                            base_logger=base_logger,
+                        )
+                    )
+            return CustomListener(
+                app_name=self.app_name,
+                matchers=listener_matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=True,
+                base_logger=base_logger or self.base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+

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

+

Ancestors

+ +

Class variables

+
+
var base_logger : logging.Logger | None
+
+

The type of the None singleton.

+
+
var thread_context_storeAssistantThreadContextStore | None
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def default_thread_context_changed(save_thread_context: SaveThreadContext,
payload: dict)
+
+
+
+ +Expand source code + +
@staticmethod
+def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict):
+    save_thread_context(payload["assistant_thread"]["context"])
+
+
+
+
+

Methods

+
+
+def bot_message(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def bot_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._bot_message_listeners is None:
+        self._bot_message_listeners = []
+    all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._bot_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def build_listener(self,
listener_or_functions: Listener | Callable | List[Callable],
matchers: List[ListenerMatcher | Callable[..., bool]] | None = None,
middleware: List[Middleware] | None = None,
base_logger: logging.Logger | None = None) ‑> Listener
+
+
+
+ +Expand source code + +
def build_listener(
+    self,
+    listener_or_functions: Union[Listener, Callable, List[Callable]],
+    matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None,
+    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, 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)
+
+        matchers = matchers if matchers else []
+        listener_matchers: List[ListenerMatcher] = []
+        for matcher in matchers:
+            if isinstance(matcher, ListenerMatcher):
+                listener_matchers.append(matcher)
+            elif isinstance(matcher, Callable):  # type: ignore[arg-type]
+                listener_matchers.append(
+                    build_listener_matcher(
+                        func=matcher,
+                        asyncio=False,
+                        base_logger=base_logger,
+                    )
+                )
+        return CustomListener(
+            app_name=self.app_name,
+            matchers=listener_matchers,
+            middleware=middleware,
+            ack_function=ack_function,
+            lazy_functions=functions,
+            auto_acknowledgement=True,
+            base_logger=base_logger or self.base_logger,
+        )
+    else:
+        raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected")
+
+
+
+
+def thread_context_changed(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_context_changed(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_context_changed_listeners is None:
+        self._thread_context_changed_listeners = []
+    all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_context_changed_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def thread_started(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def thread_started(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._thread_started_listeners is None:
+        self._thread_started_listeners = []
+    all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._thread_started_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+def user_message(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def user_message(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    if self._user_message_listeners is None:
+        self._user_message_listeners = []
+    all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers)
+    if is_used_without_argument(args):
+        func = args[0]
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=func,
+                matchers=all_matchers,
+                middleware=middleware,  # type: ignore[arg-type]
+            )
+        )
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._user_message_listeners.append(
+            self.build_listener(
+                listener_or_functions=functions,
+                matchers=all_matchers,
+                middleware=middleware,
+            )
+        )
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/async_builtins.html b/docs/reference/middleware/async_builtins.html new file mode 100644 index 000000000..8f7b1ba4f --- /dev/null +++ b/docs/reference/middleware/async_builtins.html @@ -0,0 +1,522 @@ + + + + + + +slack_bolt.middleware.async_builtins API documentation + + + + + + + + + + + +
+
+
+

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 +
+
+
+ +Expand source code + +
class AsyncAttachingFunctionToken(AsyncMiddleware):
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # This method is not supposed to be invoked by bolt-python users
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if req.context.function_bot_access_token is not None:
+            req.context.client.token = req.context.function_bot_access_token
+
+        return await next()
+
+

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

+

Ancestors

+ +

Inherited members

+ +
+
+class AsyncIgnoringSelfEvents +(base_logger: logging.Logger | None = None,
ignoring_self_assistant_message_events_enabled: bool = True)
+
+
+
+ +Expand source code + +
class AsyncIgnoringSelfEvents(IgnoringSelfEvents, AsyncMiddleware):
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        auth_result = req.context.authorize_result
+        # message events can have $.event.bot_id while it does not have its user_id
+        bot_id = req.body.get("event", {}).get("bot_id")
+        if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body):  # type: ignore[arg-type]
+            if self.ignoring_self_assistant_message_events_enabled is False:
+                if is_bot_message_event_in_assistant_thread(req.body):
+                    # Assistant#bot_message handler acknowledges this pattern
+                    return await next()
+
+            self._debug_log(req.body)
+            return await req.context.ack()
+        else:
+            return await next()
+
+

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

+

Ignores the events generated by this bot user itself.

+

Ancestors

+ +

Inherited members

+ +
+
+class AsyncMessageListenerMatches +(keyword: str | Pattern) +
+
+
+ +Expand source code + +
class AsyncMessageListenerMatches(AsyncMiddleware):
+    def __init__(self, keyword: Union[str, Pattern]):
+        """Captures matched keywords and saves the values in context."""
+        self.keyword = keyword
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        text = req.body.get("event", {}).get("text", "")
+        if text:
+            m: Optional[Union[Sequence]] = re.findall(self.keyword, text)
+            if m is not None and m != []:
+                if type(m[0]) is not tuple:
+                    m = tuple(m)
+                else:
+                    m = m[0]
+                req.context["matches"] = m  # tuple or list
+                return await next()
+
+        # As the text doesn't match, skip running the listener
+        return resp
+
+

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

+

Captures matched keywords and saves the values in context.

+

Ancestors

+ +

Inherited members

+ +
+
+class AsyncRequestVerification +(signing_secret: str, base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class AsyncRequestVerification(RequestVerification, AsyncMiddleware):
+    """Verifies an incoming request by checking the validity of
+    `x-slack-signature`, `x-slack-request-timestamp`, and its body data.
+
+    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.
+    """
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if self._can_skip(req.mode, req.body):
+            return await next()
+
+        body = req.raw_body
+        timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0]
+        signature = req.headers.get("x-slack-signature", [""])[0]
+        if self.verifier.is_valid(body, timestamp, signature):
+            return await next()
+        else:
+            self._debug_log_error(signature, timestamp, body)
+            return self._build_error_response()
+
+

Verifies an incoming request by checking the validity of +x-slack-signature, x-slack-request-timestamp, and its body data.

+

Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

+

Verifies an incoming request by checking the validity of +x-slack-signature, x-slack-request-timestamp, and its body data.

+

Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

+

Args

+
+
signing_secret
+
The signing secret
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+class AsyncSslCheck +(verification_token: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncSslCheck(SslCheck, AsyncMiddleware):
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if self._is_ssl_check_request(req.body):
+            if self._verify_token_if_needed(req.body):
+                return self._build_error_response()
+            return self._build_success_response()
+        else:
+            return await next()
+
+

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

+

Handles ssl_check requests. +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

+

Args

+
+
verification_token
+
The verification token to check +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+class AsyncUrlVerification +(base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class AsyncUrlVerification(UrlVerification, AsyncMiddleware):
+    def __init__(self, base_logger: Optional[Logger] = None):
+        self.logger = get_bolt_logger(AsyncUrlVerification, base_logger=base_logger)
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if self._is_url_verification_request(req.body):
+            return self._build_success_response(req.body)
+        else:
+            return await next()
+
+

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

+

Handles url_verification requests.

+

Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

+

Args

+
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/async_custom_middleware.html b/docs/reference/middleware/async_custom_middleware.html new file mode 100644 index 000000000..d985458ed --- /dev/null +++ b/docs/reference/middleware/async_custom_middleware.html @@ -0,0 +1,172 @@ + + + + + + +slack_bolt.middleware.async_custom_middleware API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.async_custom_middleware

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncCustomMiddleware +(*,
app_name: str,
func: Callable[..., Awaitable[Any]],
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncCustomMiddleware(AsyncMiddleware):
+    app_name: str
+    func: Callable[..., Awaitable[Any]]
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        *,
+        app_name: str,
+        func: Callable[..., Awaitable[Any]],
+        base_logger: Optional[Logger] = None,
+    ):
+        self.app_name = app_name
+        if is_callable_coroutine(func):
+            self.func = func
+        else:
+            raise ValueError("Async middleware function must be an async function")
+
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        return await self.func(
+            **build_async_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                next_func=next,  # type: ignore[arg-type]
+                this_func=self.func,
+            )
+        )
+
+    @property
+    def name(self) -> str:
+        return f"AsyncCustomMiddleware(func={get_name_for_callable(self.func)})"
+
+

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

+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., Awaitable[Any]]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/async_middleware.html b/docs/reference/middleware/async_middleware.html new file mode 100644 index 000000000..f7713b881 --- /dev/null +++ b/docs/reference/middleware/async_middleware.html @@ -0,0 +1,239 @@ + + + + + + +slack_bolt.middleware.async_middleware API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.async_middleware

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncMiddleware +
+
+
+ +Expand source code + +
class AsyncMiddleware(metaclass=ABCMeta):
+    """A middleware can process request data before other middleware and listener functions."""
+
+    @abstractmethod
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> Optional[BoltResponse]:
+        """Processes a request data before other middleware and listeners.
+        A middleware calls `next()` function if the chain should continue.
+
+            @app.middleware
+            async def simple_middleware(req, resp, next):
+                # do something here
+                await next()
+
+        This `async_process(req, resp, next)` method is supposed to be invoked only inside bolt-python.
+        If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead.
+
+            @app.middleware
+            async def simple_middleware(req, resp, next_):
+                # do something here
+                await next_()
+
+        Args:
+            req: The incoming request
+            resp: The response
+            next: The function to tell the chain that it can continue
+
+        Returns:
+            Processed response (optional)
+        """
+        raise NotImplementedError()
+
+    @property
+    def name(self) -> str:
+        """The name of this middleware"""
+        return f"{self.__module__}.{self.__class__.__name__}"
+
+

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

+

Subclasses

+ +

Instance variables

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this middleware"""
+    return f"{self.__module__}.{self.__class__.__name__}"
+
+

The name of this middleware

+
+
+

Methods

+
+
+async def async_process(self,
*,
req: AsyncBoltRequest,
resp: BoltResponse,
next: Callable[[], Awaitable[BoltResponse]]) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+async def async_process(
+    self,
+    *,
+    req: AsyncBoltRequest,
+    resp: BoltResponse,
+    # As this method is not supposed to be invoked by bolt-python users,
+    # the naming conflict with the built-in one affects
+    # only the internals of this method
+    next: Callable[[], Awaitable[BoltResponse]],
+) -> Optional[BoltResponse]:
+    """Processes a request data before other middleware and listeners.
+    A middleware calls `next()` function if the chain should continue.
+
+        @app.middleware
+        async def simple_middleware(req, resp, next):
+            # do something here
+            await next()
+
+    This `async_process(req, resp, next)` method is supposed to be invoked only inside bolt-python.
+    If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead.
+
+        @app.middleware
+        async def simple_middleware(req, resp, next_):
+            # do something here
+            await next_()
+
+    Args:
+        req: The incoming request
+        resp: The response
+        next: The function to tell the chain that it can continue
+
+    Returns:
+        Processed response (optional)
+    """
+    raise NotImplementedError()
+
+

Processes a request data before other middleware and listeners. +A middleware calls next() function if the chain should continue.

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

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

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

Args

+
+
req
+
The incoming request
+
resp
+
The response
+
next
+
The function to tell the chain that it can continue
+
+

Returns

+

Processed response (optional)

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/async_middleware_error_handler.html b/docs/reference/middleware/async_middleware_error_handler.html new file mode 100644 index 000000000..bf5b101f6 --- /dev/null +++ b/docs/reference/middleware/async_middleware_error_handler.html @@ -0,0 +1,241 @@ + + + + + + +slack_bolt.middleware.async_middleware_error_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.async_middleware_error_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

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

+ +

Inherited members

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

Ancestors

+ +

Inherited members

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

Subclasses

+ +

Methods

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

Handles an unhandled exception.

+

Args

+
+
error
+
The raised exception.
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + 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/attaching_function_token/async_attaching_function_token.html b/docs/reference/middleware/attaching_function_token/async_attaching_function_token.html new file mode 100644 index 000000000..1becac04e --- /dev/null +++ b/docs/reference/middleware/attaching_function_token/async_attaching_function_token.html @@ -0,0 +1,113 @@ + + + + + + +slack_bolt.middleware.attaching_function_token.async_attaching_function_token API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.attaching_function_token.async_attaching_function_token

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncAttachingFunctionToken +
+
+
+ +Expand source code + +
class AsyncAttachingFunctionToken(AsyncMiddleware):
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # This method is not supposed to be invoked by bolt-python users
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if req.context.function_bot_access_token is not None:
+            req.context.client.token = req.context.function_bot_access_token
+
+        return await next()
+
+

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

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/attaching_function_token/attaching_function_token.html b/docs/reference/middleware/attaching_function_token/attaching_function_token.html new file mode 100644 index 000000000..8eea36647 --- /dev/null +++ b/docs/reference/middleware/attaching_function_token/attaching_function_token.html @@ -0,0 +1,113 @@ + + + + + + +slack_bolt.middleware.attaching_function_token.attaching_function_token API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.attaching_function_token.attaching_function_token

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AttachingFunctionToken +
+
+
+ +Expand source code + +
class AttachingFunctionToken(Middleware):
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # This method is not supposed to be invoked by bolt-python users
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if req.context.function_bot_access_token is not None:
+            req.context.client.token = req.context.function_bot_access_token
+
+        return next()
+
+

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

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/attaching_function_token/index.html b/docs/reference/middleware/attaching_function_token/index.html new file mode 100644 index 000000000..44efd27a2 --- /dev/null +++ b/docs/reference/middleware/attaching_function_token/index.html @@ -0,0 +1,130 @@ + + + + + + +slack_bolt.middleware.attaching_function_token API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.attaching_function_token

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.attaching_function_token.async_attaching_function_token
+
+
+
+
slack_bolt.middleware.attaching_function_token.attaching_function_token
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AttachingFunctionToken +
+
+
+ +Expand source code + +
class AttachingFunctionToken(Middleware):
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # This method is not supposed to be invoked by bolt-python users
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if req.context.function_bot_access_token is not None:
+            req.context.client.token = req.context.function_bot_access_token
+
+        return next()
+
+

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

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/async_authorization.html b/docs/reference/middleware/authorization/async_authorization.html new file mode 100644 index 000000000..9f38ea711 --- /dev/null +++ b/docs/reference/middleware/authorization/async_authorization.html @@ -0,0 +1,108 @@ + + + + + + +slack_bolt.middleware.authorization.async_authorization API documentation + + + + + + + + + + + +
+ + +
+ + + diff --git a/docs/reference/middleware/authorization/async_internals.html b/docs/reference/middleware/authorization/async_internals.html new file mode 100644 index 000000000..22b709799 --- /dev/null +++ b/docs/reference/middleware/authorization/async_internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.middleware.authorization.async_internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization.async_internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/async_multi_teams_authorization.html b/docs/reference/middleware/authorization/async_multi_teams_authorization.html new file mode 100644 index 000000000..50b529f33 --- /dev/null +++ b/docs/reference/middleware/authorization/async_multi_teams_authorization.html @@ -0,0 +1,222 @@ + + + + + + +slack_bolt.middleware.authorization.async_multi_teams_authorization API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization.async_multi_teams_authorization

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncMultiTeamsAuthorization +(authorize: AsyncAuthorize,
base_logger: logging.Logger | None = None,
user_token_resolution: str = 'authed_user',
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class AsyncMultiTeamsAuthorization(AsyncAuthorization):
+    authorize: AsyncAuthorize
+    user_token_resolution: str
+
+    def __init__(
+        self,
+        authorize: AsyncAuthorize,
+        base_logger: Optional[Logger] = None,
+        user_token_resolution: str = "authed_user",
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Multi-workspace authorization.
+
+        Args:
+            authorize: The function to authorize incoming requests from Slack.
+            base_logger: The base logger
+            user_token_resolution: "authed_user" or "actor"
+            user_facing_authorize_error_message: The user-facing error message when installation is not found
+        """
+        self.authorize = authorize
+        self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger)
+        self.user_token_resolution = user_token_resolution
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if _is_no_auth_required(req):
+            return await next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return await next()
+
+        try:
+            auth_result: Optional[AuthorizeResult] = None
+            if self.user_token_resolution == "actor":
+                auth_result = await self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                    actor_enterprise_id=req.context.actor_enterprise_id,
+                    actor_team_id=req.context.actor_team_id,
+                    actor_user_id=req.context.actor_user_id,
+                )
+            else:
+                auth_result = await self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            if auth_result:
+                req.context.set_authorize_result(auth_result)
+                token = auth_result.bot_token or auth_result.user_token
+                req.context["token"] = token
+                # As AsyncApp#_init_context() generates a new AsyncWebClient for this request,
+                # it's safe to modify this instance.
+                req.context.client.token = token
+                return await next()
+            else:
+                # This situation can arise if:
+                # * A developer installed the app from the "Install to Workspace" button in Slack app config page
+                # * The InstallationStore failed to save or deleted the installation for this workspace
+                self.logger.error(
+                    "Although the app should be installed into this workspace, "
+                    "the AuthorizeResult (returned value from authorize) for it was not found."
+                )
+                if req.context.response_url is not None:
+                    await req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Multi-workspace authorization.

+

Args

+
+
authorize
+
The function to authorize incoming requests from Slack.
+
base_logger
+
The base logger
+
user_token_resolution
+
"authed_user" or "actor"
+
user_facing_authorize_error_message
+
The user-facing error message when installation is not found
+
+

Ancestors

+ +

Class variables

+
+
var authorizeAsyncAuthorize
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/async_single_team_authorization.html b/docs/reference/middleware/authorization/async_single_team_authorization.html new file mode 100644 index 000000000..a167d1c68 --- /dev/null +++ b/docs/reference/middleware/authorization/async_single_team_authorization.html @@ -0,0 +1,163 @@ + + + + + + +slack_bolt.middleware.authorization.async_single_team_authorization API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization.async_single_team_authorization

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSingleTeamAuthorization +(base_logger: logging.Logger | None = None,
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class AsyncSingleTeamAuthorization(AsyncAuthorization):
+    def __init__(
+        self,
+        base_logger: Optional[Logger] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Single-workspace authorization."""
+        self.auth_test_result: Optional[AsyncSlackResponse] = None
+        self.logger = get_bolt_logger(AsyncSingleTeamAuthorization, base_logger=base_logger)
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if _is_no_auth_required(req):
+            return await next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return await next()
+
+        try:
+            if self.auth_test_result is None:
+                self.auth_test_result = await req.context.client.auth_test()
+
+            if self.auth_test_result:
+                req.context.set_authorize_result(
+                    _to_authorize_result(
+                        auth_test_result=self.auth_test_result,
+                        token=req.context.client.token,
+                        request_user_id=req.context.user_id,
+                    )
+                )
+                return await next()
+            else:
+                # Just in case
+                self.logger.error("auth.test API call result is unexpectedly None")
+                if req.context.response_url is not None:
+                    await req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Single-workspace authorization.

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/authorization.html b/docs/reference/middleware/authorization/authorization.html new file mode 100644 index 000000000..7ddd4ce41 --- /dev/null +++ b/docs/reference/middleware/authorization/authorization.html @@ -0,0 +1,107 @@ + + + + + + +slack_bolt.middleware.authorization.authorization API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization.authorization

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Authorization +
+
+
+ +Expand source code + +
class Authorization(Middleware):
+    pass
+
+

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

+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/index.html b/docs/reference/middleware/authorization/index.html new file mode 100644 index 000000000..9f5c3f393 --- /dev/null +++ b/docs/reference/middleware/authorization/index.html @@ -0,0 +1,404 @@ + + + + + + +slack_bolt.middleware.authorization API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.authorization.async_authorization
+
+
+
+
slack_bolt.middleware.authorization.async_internals
+
+
+
+
slack_bolt.middleware.authorization.async_multi_teams_authorization
+
+
+
+
slack_bolt.middleware.authorization.async_single_team_authorization
+
+
+
+
slack_bolt.middleware.authorization.authorization
+
+
+
+
slack_bolt.middleware.authorization.internals
+
+
+
+
slack_bolt.middleware.authorization.multi_teams_authorization
+
+
+
+
slack_bolt.middleware.authorization.single_team_authorization
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Authorization +
+
+
+ +Expand source code + +
class Authorization(Middleware):
+    pass
+
+

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

+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+class MultiTeamsAuthorization +(*,
authorize: Authorize,
base_logger: logging.Logger | None = None,
user_token_resolution: str = 'authed_user',
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class MultiTeamsAuthorization(Authorization):
+    authorize: Authorize
+    user_token_resolution: str
+
+    def __init__(
+        self,
+        *,
+        authorize: Authorize,
+        base_logger: Optional[Logger] = None,
+        user_token_resolution: str = "authed_user",
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Multi-workspace authorization.
+
+        Args:
+            authorize: The function to authorize incoming requests from Slack.
+            base_logger: The base logger
+            user_token_resolution: "authed_user" or "actor"
+            user_facing_authorize_error_message: The user-facing error message when installation is not found
+        """
+        self.authorize = authorize
+        self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger)
+        self.user_token_resolution = user_token_resolution
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if _is_no_auth_required(req):
+            return next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return next()
+
+        try:
+            auth_result: Optional[AuthorizeResult] = None
+            if self.user_token_resolution == "actor":
+                auth_result = self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                    actor_enterprise_id=req.context.actor_enterprise_id,
+                    actor_team_id=req.context.actor_team_id,
+                    actor_user_id=req.context.actor_user_id,
+                )
+            else:
+                auth_result = self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            if auth_result is not None:
+                req.context.set_authorize_result(auth_result)
+                token = auth_result.bot_token or auth_result.user_token
+                req.context["token"] = token
+                # As App#_init_context() generates a new WebClient for this request,
+                # it's safe to modify this instance.
+                req.context.client.token = token
+                return next()
+            else:
+                # This situation can arise if:
+                # * A developer installed the app from the "Install to Workspace" button in Slack app config page
+                # * The InstallationStore failed to save or deleted the installation for this workspace
+                self.logger.error(
+                    "Although the app should be installed into this workspace, "
+                    "the AuthorizeResult (returned value from authorize) for it was not found."
+                )
+                if req.context.response_url is not None:
+                    req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Multi-workspace authorization.

+

Args

+
+
authorize
+
The function to authorize incoming requests from Slack.
+
base_logger
+
The base logger
+
user_token_resolution
+
"authed_user" or "actor"
+
user_facing_authorize_error_message
+
The user-facing error message when installation is not found
+
+

Ancestors

+ +

Class variables

+
+
var authorizeAuthorize
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class SingleTeamAuthorization +(*,
auth_test_result: slack_sdk.web.slack_response.SlackResponse | None = None,
base_logger: logging.Logger | None = None,
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class SingleTeamAuthorization(Authorization):
+    def __init__(
+        self,
+        *,
+        auth_test_result: Optional[SlackResponse] = None,
+        base_logger: Optional[Logger] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Single-workspace authorization.
+
+        Args:
+            auth_test_result: The initial `auth.test` API call result.
+            base_logger: The base logger
+        """
+        self.auth_test_result = auth_test_result
+        self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger)
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+
+        if _is_no_auth_required(req):
+            return next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return next()
+
+        try:
+            if not self.auth_test_result:
+                self.auth_test_result = req.context.client.auth_test()
+
+            if self.auth_test_result:
+                req.context.set_authorize_result(
+                    _to_authorize_result(
+                        auth_test_result=self.auth_test_result,
+                        token=req.context.client.token,
+                        request_user_id=req.context.user_id,
+                    )
+                )
+                return next()
+            else:
+                # Just in case
+                self.logger.error("auth.test API call result is unexpectedly None")
+                if req.context.response_url is not None:
+                    req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Single-workspace authorization.

+

Args

+
+
auth_test_result
+
The initial auth.test API call result.
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/internals.html b/docs/reference/middleware/authorization/internals.html new file mode 100644 index 000000000..c64a7e0f3 --- /dev/null +++ b/docs/reference/middleware/authorization/internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.middleware.authorization.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization.internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/multi_teams_authorization.html b/docs/reference/middleware/authorization/multi_teams_authorization.html new file mode 100644 index 000000000..c2a6a7964 --- /dev/null +++ b/docs/reference/middleware/authorization/multi_teams_authorization.html @@ -0,0 +1,219 @@ + + + + + + +slack_bolt.middleware.authorization.multi_teams_authorization API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization.multi_teams_authorization

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class MultiTeamsAuthorization +(*,
authorize: Authorize,
base_logger: logging.Logger | None = None,
user_token_resolution: str = 'authed_user',
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class MultiTeamsAuthorization(Authorization):
+    authorize: Authorize
+    user_token_resolution: str
+
+    def __init__(
+        self,
+        *,
+        authorize: Authorize,
+        base_logger: Optional[Logger] = None,
+        user_token_resolution: str = "authed_user",
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Multi-workspace authorization.
+
+        Args:
+            authorize: The function to authorize incoming requests from Slack.
+            base_logger: The base logger
+            user_token_resolution: "authed_user" or "actor"
+            user_facing_authorize_error_message: The user-facing error message when installation is not found
+        """
+        self.authorize = authorize
+        self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger)
+        self.user_token_resolution = user_token_resolution
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if _is_no_auth_required(req):
+            return next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return next()
+
+        try:
+            auth_result: Optional[AuthorizeResult] = None
+            if self.user_token_resolution == "actor":
+                auth_result = self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                    actor_enterprise_id=req.context.actor_enterprise_id,
+                    actor_team_id=req.context.actor_team_id,
+                    actor_user_id=req.context.actor_user_id,
+                )
+            else:
+                auth_result = self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            if auth_result is not None:
+                req.context.set_authorize_result(auth_result)
+                token = auth_result.bot_token or auth_result.user_token
+                req.context["token"] = token
+                # As App#_init_context() generates a new WebClient for this request,
+                # it's safe to modify this instance.
+                req.context.client.token = token
+                return next()
+            else:
+                # This situation can arise if:
+                # * A developer installed the app from the "Install to Workspace" button in Slack app config page
+                # * The InstallationStore failed to save or deleted the installation for this workspace
+                self.logger.error(
+                    "Although the app should be installed into this workspace, "
+                    "the AuthorizeResult (returned value from authorize) for it was not found."
+                )
+                if req.context.response_url is not None:
+                    req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Multi-workspace authorization.

+

Args

+
+
authorize
+
The function to authorize incoming requests from Slack.
+
base_logger
+
The base logger
+
user_token_resolution
+
"authed_user" or "actor"
+
user_facing_authorize_error_message
+
The user-facing error message when installation is not found
+
+

Ancestors

+ +

Class variables

+
+
var authorizeAuthorize
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/authorization/single_team_authorization.html b/docs/reference/middleware/authorization/single_team_authorization.html new file mode 100644 index 000000000..7687be155 --- /dev/null +++ b/docs/reference/middleware/authorization/single_team_authorization.html @@ -0,0 +1,177 @@ + + + + + + +slack_bolt.middleware.authorization.single_team_authorization API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.authorization.single_team_authorization

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SingleTeamAuthorization +(*,
auth_test_result: slack_sdk.web.slack_response.SlackResponse | None = None,
base_logger: logging.Logger | None = None,
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class SingleTeamAuthorization(Authorization):
+    def __init__(
+        self,
+        *,
+        auth_test_result: Optional[SlackResponse] = None,
+        base_logger: Optional[Logger] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Single-workspace authorization.
+
+        Args:
+            auth_test_result: The initial `auth.test` API call result.
+            base_logger: The base logger
+        """
+        self.auth_test_result = auth_test_result
+        self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger)
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+
+        if _is_no_auth_required(req):
+            return next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return next()
+
+        try:
+            if not self.auth_test_result:
+                self.auth_test_result = req.context.client.auth_test()
+
+            if self.auth_test_result:
+                req.context.set_authorize_result(
+                    _to_authorize_result(
+                        auth_test_result=self.auth_test_result,
+                        token=req.context.client.token,
+                        request_user_id=req.context.user_id,
+                    )
+                )
+                return next()
+            else:
+                # Just in case
+                self.logger.error("auth.test API call result is unexpectedly None")
+                if req.context.response_url is not None:
+                    req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Single-workspace authorization.

+

Args

+
+
auth_test_result
+
The initial auth.test API call result.
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/custom_middleware.html b/docs/reference/middleware/custom_middleware.html new file mode 100644 index 000000000..aba9dc14b --- /dev/null +++ b/docs/reference/middleware/custom_middleware.html @@ -0,0 +1,162 @@ + + + + + + +slack_bolt.middleware.custom_middleware API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.custom_middleware

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomMiddleware +(*, app_name: str, func: Callable, base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class CustomMiddleware(Middleware):
+    app_name: str
+    func: Callable[..., Any]
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable, base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        return self.func(
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                next_func=next,  # type: ignore[arg-type]
+                this_func=self.func,
+            )
+        )
+
+    @property
+    def name(self) -> str:
+        return f"CustomMiddleware(func={get_name_for_callable(self.func)})"
+
+

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

+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., Any]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html b/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html new file mode 100644 index 000000000..4d48b16b9 --- /dev/null +++ b/docs/reference/middleware/ignoring_self_events/async_ignoring_self_events.html @@ -0,0 +1,131 @@ + + + + + + +slack_bolt.middleware.ignoring_self_events.async_ignoring_self_events API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.ignoring_self_events.async_ignoring_self_events

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncIgnoringSelfEvents +(base_logger: logging.Logger | None = None,
ignoring_self_assistant_message_events_enabled: bool = True)
+
+
+
+ +Expand source code + +
class AsyncIgnoringSelfEvents(IgnoringSelfEvents, AsyncMiddleware):
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        auth_result = req.context.authorize_result
+        # message events can have $.event.bot_id while it does not have its user_id
+        bot_id = req.body.get("event", {}).get("bot_id")
+        if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body):  # type: ignore[arg-type]
+            if self.ignoring_self_assistant_message_events_enabled is False:
+                if is_bot_message_event_in_assistant_thread(req.body):
+                    # Assistant#bot_message handler acknowledges this pattern
+                    return await next()
+
+            self._debug_log(req.body)
+            return await req.context.ack()
+        else:
+            return await next()
+
+

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

+

Ignores the events generated by this bot user itself.

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html b/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html new file mode 100644 index 000000000..111c096c4 --- /dev/null +++ b/docs/reference/middleware/ignoring_self_events/ignoring_self_events.html @@ -0,0 +1,176 @@ + + + + + + +slack_bolt.middleware.ignoring_self_events.ignoring_self_events API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.ignoring_self_events.ignoring_self_events

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class IgnoringSelfEvents +(base_logger: logging.Logger | None = None,
ignoring_self_assistant_message_events_enabled: bool = True)
+
+
+
+ +Expand source code + +
class IgnoringSelfEvents(Middleware):
+    def __init__(
+        self,
+        base_logger: Optional[logging.Logger] = None,
+        ignoring_self_assistant_message_events_enabled: bool = True,
+    ):
+        """Ignores the events generated by this bot user itself."""
+        self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger)
+        self.ignoring_self_assistant_message_events_enabled = ignoring_self_assistant_message_events_enabled
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        auth_result = req.context.authorize_result
+        # message events can have $.event.bot_id while it does not have its user_id
+        bot_id = req.body.get("event", {}).get("bot_id")
+        if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body):  # type: ignore[arg-type]
+            if self.ignoring_self_assistant_message_events_enabled is False:
+                if is_bot_message_event_in_assistant_thread(req.body):
+                    # Assistant#bot_message handler acknowledges this pattern
+                    return next()
+
+            self._debug_log(req.body)
+            return req.context.ack()
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    # It's an Events API event that isn't of type message,
+    # but the user ID might match our own app. Filter these out.
+    # However, some events still must be fired, because they can make sense.
+    events_that_should_be_kept = ["member_joined_channel", "member_left_channel"]
+
+    @classmethod
+    def _is_self_event(
+        cls,
+        auth_result: AuthorizeResult,
+        user_id: Optional[str],
+        bot_id: Optional[str],
+        body: Dict[str, Any],
+    ):
+        return (
+            auth_result is not None
+            and (
+                (user_id is not None and user_id == auth_result.bot_user_id)
+                or (bot_id is not None and bot_id == auth_result.bot_id)  # for bot_message events
+            )
+            and body.get("event") is not None
+            and body.get("event", {}).get("type") not in cls.events_that_should_be_kept
+        )
+
+    def _debug_log(self, body: dict):
+        if self.logger.level <= logging.DEBUG:
+            event = body.get("event")
+            self.logger.debug(f"Skipped self event: {event}")
+
+

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

+

Ignores the events generated by this bot user itself.

+

Ancestors

+ +

Subclasses

+ +

Class variables

+
+
var events_that_should_be_kept
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/ignoring_self_events/index.html b/docs/reference/middleware/ignoring_self_events/index.html new file mode 100644 index 000000000..f81603f4a --- /dev/null +++ b/docs/reference/middleware/ignoring_self_events/index.html @@ -0,0 +1,193 @@ + + + + + + +slack_bolt.middleware.ignoring_self_events API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.ignoring_self_events

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.ignoring_self_events.async_ignoring_self_events
+
+
+
+
slack_bolt.middleware.ignoring_self_events.ignoring_self_events
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class IgnoringSelfEvents +(base_logger: logging.Logger | None = None,
ignoring_self_assistant_message_events_enabled: bool = True)
+
+
+
+ +Expand source code + +
class IgnoringSelfEvents(Middleware):
+    def __init__(
+        self,
+        base_logger: Optional[logging.Logger] = None,
+        ignoring_self_assistant_message_events_enabled: bool = True,
+    ):
+        """Ignores the events generated by this bot user itself."""
+        self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger)
+        self.ignoring_self_assistant_message_events_enabled = ignoring_self_assistant_message_events_enabled
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        auth_result = req.context.authorize_result
+        # message events can have $.event.bot_id while it does not have its user_id
+        bot_id = req.body.get("event", {}).get("bot_id")
+        if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body):  # type: ignore[arg-type]
+            if self.ignoring_self_assistant_message_events_enabled is False:
+                if is_bot_message_event_in_assistant_thread(req.body):
+                    # Assistant#bot_message handler acknowledges this pattern
+                    return next()
+
+            self._debug_log(req.body)
+            return req.context.ack()
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    # It's an Events API event that isn't of type message,
+    # but the user ID might match our own app. Filter these out.
+    # However, some events still must be fired, because they can make sense.
+    events_that_should_be_kept = ["member_joined_channel", "member_left_channel"]
+
+    @classmethod
+    def _is_self_event(
+        cls,
+        auth_result: AuthorizeResult,
+        user_id: Optional[str],
+        bot_id: Optional[str],
+        body: Dict[str, Any],
+    ):
+        return (
+            auth_result is not None
+            and (
+                (user_id is not None and user_id == auth_result.bot_user_id)
+                or (bot_id is not None and bot_id == auth_result.bot_id)  # for bot_message events
+            )
+            and body.get("event") is not None
+            and body.get("event", {}).get("type") not in cls.events_that_should_be_kept
+        )
+
+    def _debug_log(self, body: dict):
+        if self.logger.level <= logging.DEBUG:
+            event = body.get("event")
+            self.logger.debug(f"Skipped self event: {event}")
+
+

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

+

Ignores the events generated by this bot user itself.

+

Ancestors

+ +

Subclasses

+ +

Class variables

+
+
var events_that_should_be_kept
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/index.html b/docs/reference/middleware/index.html new file mode 100644 index 000000000..153342bc1 --- /dev/null +++ b/docs/reference/middleware/index.html @@ -0,0 +1,1210 @@ + + + + + + +slack_bolt.middleware API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware

+
+
+

A middleware processes request data and calls next() method +if the execution chain should continue running the following middleware.

+

Middleware can be used globally before all listener executions. +It's also possible to run a middleware only for a particular listener.

+
+
+

Sub-modules

+
+
slack_bolt.middleware.assistant
+
+
+
+
slack_bolt.middleware.async_builtins
+
+
+
+
slack_bolt.middleware.async_custom_middleware
+
+
+
+
slack_bolt.middleware.async_middleware
+
+
+
+
slack_bolt.middleware.async_middleware_error_handler
+
+
+
+
slack_bolt.middleware.attaching_conversation_kwargs
+
+
+
+
slack_bolt.middleware.attaching_function_token
+
+
+
+
slack_bolt.middleware.authorization
+
+
+
+
slack_bolt.middleware.custom_middleware
+
+
+
+
slack_bolt.middleware.ignoring_self_events
+
+
+
+
slack_bolt.middleware.message_listener_matches
+
+
+
+
slack_bolt.middleware.middleware
+
+
+
+
slack_bolt.middleware.middleware_error_handler
+
+
+
+
slack_bolt.middleware.request_verification
+
+
+
+
slack_bolt.middleware.ssl_check
+
+
+
+
slack_bolt.middleware.url_verification
+
+
+
+
+
+
+
+
+
+
+

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 +
+
+
+ +Expand source code + +
class AttachingFunctionToken(Middleware):
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # This method is not supposed to be invoked by bolt-python users
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if req.context.function_bot_access_token is not None:
+            req.context.client.token = req.context.function_bot_access_token
+
+        return next()
+
+

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

+

Ancestors

+ +

Inherited members

+ +
+
+class CustomMiddleware +(*, app_name: str, func: Callable, base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class CustomMiddleware(Middleware):
+    app_name: str
+    func: Callable[..., Any]
+    arg_names: MutableSequence[str]
+    logger: Logger
+
+    def __init__(self, *, app_name: str, func: Callable, base_logger: Optional[Logger] = None):
+        self.app_name = app_name
+        self.func = func
+        self.arg_names = get_arg_names_of_callable(func)
+        self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        return self.func(
+            **build_required_kwargs(
+                logger=self.logger,
+                required_arg_names=self.arg_names,
+                request=req,
+                response=resp,
+                next_func=next,  # type: ignore[arg-type]
+                this_func=self.func,
+            )
+        )
+
+    @property
+    def name(self) -> str:
+        return f"CustomMiddleware(func={get_name_for_callable(self.func)})"
+
+

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

+

Ancestors

+ +

Class variables

+
+
var app_name : str
+
+

The type of the None singleton.

+
+
var arg_names : MutableSequence[str]
+
+

The type of the None singleton.

+
+
var func : Callable[..., Any]
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class IgnoringSelfEvents +(base_logger: logging.Logger | None = None,
ignoring_self_assistant_message_events_enabled: bool = True)
+
+
+
+ +Expand source code + +
class IgnoringSelfEvents(Middleware):
+    def __init__(
+        self,
+        base_logger: Optional[logging.Logger] = None,
+        ignoring_self_assistant_message_events_enabled: bool = True,
+    ):
+        """Ignores the events generated by this bot user itself."""
+        self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger)
+        self.ignoring_self_assistant_message_events_enabled = ignoring_self_assistant_message_events_enabled
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        auth_result = req.context.authorize_result
+        # message events can have $.event.bot_id while it does not have its user_id
+        bot_id = req.body.get("event", {}).get("bot_id")
+        if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body):  # type: ignore[arg-type]
+            if self.ignoring_self_assistant_message_events_enabled is False:
+                if is_bot_message_event_in_assistant_thread(req.body):
+                    # Assistant#bot_message handler acknowledges this pattern
+                    return next()
+
+            self._debug_log(req.body)
+            return req.context.ack()
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    # It's an Events API event that isn't of type message,
+    # but the user ID might match our own app. Filter these out.
+    # However, some events still must be fired, because they can make sense.
+    events_that_should_be_kept = ["member_joined_channel", "member_left_channel"]
+
+    @classmethod
+    def _is_self_event(
+        cls,
+        auth_result: AuthorizeResult,
+        user_id: Optional[str],
+        bot_id: Optional[str],
+        body: Dict[str, Any],
+    ):
+        return (
+            auth_result is not None
+            and (
+                (user_id is not None and user_id == auth_result.bot_user_id)
+                or (bot_id is not None and bot_id == auth_result.bot_id)  # for bot_message events
+            )
+            and body.get("event") is not None
+            and body.get("event", {}).get("type") not in cls.events_that_should_be_kept
+        )
+
+    def _debug_log(self, body: dict):
+        if self.logger.level <= logging.DEBUG:
+            event = body.get("event")
+            self.logger.debug(f"Skipped self event: {event}")
+
+

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

+

Ignores the events generated by this bot user itself.

+

Ancestors

+ +

Subclasses

+ +

Class variables

+
+
var events_that_should_be_kept
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class Middleware +
+
+
+ +Expand source code + +
class Middleware(metaclass=ABCMeta):
+    """A middleware can process request data before other middleware and listener functions."""
+
+    @abstractmethod
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> Optional[BoltResponse]:
+        """Processes a request data before other middleware and listeners.
+        A middleware calls `next()` function if the chain should continue.
+
+            @app.middleware
+            def simple_middleware(req, resp, next):
+                # do something here
+                next()
+
+        This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python.
+        If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead.
+
+            @app.middleware
+            def simple_middleware(req, resp, next_):
+                # do something here
+                next_()
+
+        Args:
+            req: The incoming request
+            resp: The response
+            next: The function to tell the chain that it can continue
+
+        Returns:
+            Processed response (optional)
+        """
+        raise NotImplementedError()
+
+    @property
+    def name(self) -> str:
+        """The name of this middleware"""
+        return f"{self.__module__}.{self.__class__.__name__}"
+
+

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

+

Subclasses

+ +

Instance variables

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this middleware"""
+    return f"{self.__module__}.{self.__class__.__name__}"
+
+

The name of this middleware

+
+
+

Methods

+
+
+def process(self,
*,
req: BoltRequest,
resp: BoltResponse,
next: Callable[[], BoltResponse]) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+def process(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+    # As this method is not supposed to be invoked by bolt-python users,
+    # the naming conflict with the built-in one affects
+    # only the internals of this method
+    next: Callable[[], BoltResponse],
+) -> Optional[BoltResponse]:
+    """Processes a request data before other middleware and listeners.
+    A middleware calls `next()` function if the chain should continue.
+
+        @app.middleware
+        def simple_middleware(req, resp, next):
+            # do something here
+            next()
+
+    This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python.
+    If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead.
+
+        @app.middleware
+        def simple_middleware(req, resp, next_):
+            # do something here
+            next_()
+
+    Args:
+        req: The incoming request
+        resp: The response
+        next: The function to tell the chain that it can continue
+
+    Returns:
+        Processed response (optional)
+    """
+    raise NotImplementedError()
+
+

Processes a request data before other middleware and listeners. +A middleware calls next() function if the chain should continue.

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

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

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

Args

+
+
req
+
The incoming request
+
resp
+
The response
+
next
+
The function to tell the chain that it can continue
+
+

Returns

+

Processed response (optional)

+
+
+
+
+class MultiTeamsAuthorization +(*,
authorize: Authorize,
base_logger: logging.Logger | None = None,
user_token_resolution: str = 'authed_user',
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class MultiTeamsAuthorization(Authorization):
+    authorize: Authorize
+    user_token_resolution: str
+
+    def __init__(
+        self,
+        *,
+        authorize: Authorize,
+        base_logger: Optional[Logger] = None,
+        user_token_resolution: str = "authed_user",
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Multi-workspace authorization.
+
+        Args:
+            authorize: The function to authorize incoming requests from Slack.
+            base_logger: The base logger
+            user_token_resolution: "authed_user" or "actor"
+            user_facing_authorize_error_message: The user-facing error message when installation is not found
+        """
+        self.authorize = authorize
+        self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger)
+        self.user_token_resolution = user_token_resolution
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if _is_no_auth_required(req):
+            return next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return next()
+
+        try:
+            auth_result: Optional[AuthorizeResult] = None
+            if self.user_token_resolution == "actor":
+                auth_result = self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                    actor_enterprise_id=req.context.actor_enterprise_id,
+                    actor_team_id=req.context.actor_team_id,
+                    actor_user_id=req.context.actor_user_id,
+                )
+            else:
+                auth_result = self.authorize(
+                    context=req.context,
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            if auth_result is not None:
+                req.context.set_authorize_result(auth_result)
+                token = auth_result.bot_token or auth_result.user_token
+                req.context["token"] = token
+                # As App#_init_context() generates a new WebClient for this request,
+                # it's safe to modify this instance.
+                req.context.client.token = token
+                return next()
+            else:
+                # This situation can arise if:
+                # * A developer installed the app from the "Install to Workspace" button in Slack app config page
+                # * The InstallationStore failed to save or deleted the installation for this workspace
+                self.logger.error(
+                    "Although the app should be installed into this workspace, "
+                    "the AuthorizeResult (returned value from authorize) for it was not found."
+                )
+                if req.context.response_url is not None:
+                    req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Multi-workspace authorization.

+

Args

+
+
authorize
+
The function to authorize incoming requests from Slack.
+
base_logger
+
The base logger
+
user_token_resolution
+
"authed_user" or "actor"
+
user_facing_authorize_error_message
+
The user-facing error message when installation is not found
+
+

Ancestors

+ +

Class variables

+
+
var authorizeAuthorize
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class RequestVerification +(signing_secret: str, base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class RequestVerification(Middleware):
+    def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None):
+        """Verifies an incoming request by checking the validity of
+        `x-slack-signature`, `x-slack-request-timestamp`, and its body data.
+
+        Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.
+
+        Args:
+            signing_secret: The signing secret
+            base_logger: The base logger
+        """
+        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,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._can_skip(req.mode, req.body):
+            return next()
+
+        body = req.raw_body
+        timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0]
+        signature = req.headers.get("x-slack-signature", [""])[0]
+        if self.verifier.is_valid(body, timestamp, signature):
+            return next()
+        else:
+            self._debug_log_error(signature, timestamp, body)
+            return self._build_error_response()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _can_skip(mode: str, body: Dict[str, Any]) -> bool:
+        return mode == "socket_mode"
+
+    @staticmethod
+    def _build_error_response() -> BoltResponse:
+        return BoltResponse(status=401, body={"error": "invalid request"})
+
+    def _debug_log_error(self, signature, timestamp, body) -> None:
+        self.logger.info(
+            "Invalid request signature detected " f"(signature: {signature}, timestamp: {timestamp}, body: {body})"
+        )
+
+

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

+

Verifies an incoming request by checking the validity of +x-slack-signature, x-slack-request-timestamp, and its body data.

+

Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

+

Args

+
+
signing_secret
+
The signing secret
+
base_logger
+
The base logger
+
+

Ancestors

+ +

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

+ +
+
+class SingleTeamAuthorization +(*,
auth_test_result: slack_sdk.web.slack_response.SlackResponse | None = None,
base_logger: logging.Logger | None = None,
user_facing_authorize_error_message: str | None = None)
+
+
+
+ +Expand source code + +
class SingleTeamAuthorization(Authorization):
+    def __init__(
+        self,
+        *,
+        auth_test_result: Optional[SlackResponse] = None,
+        base_logger: Optional[Logger] = None,
+        user_facing_authorize_error_message: Optional[str] = None,
+    ):
+        """Single-workspace authorization.
+
+        Args:
+            auth_test_result: The initial `auth.test` API call result.
+            base_logger: The base logger
+        """
+        self.auth_test_result = auth_test_result
+        self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger)
+        self.user_facing_authorize_error_message = (
+            user_facing_authorize_error_message or _build_user_facing_authorize_error_message()
+        )
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+
+        if _is_no_auth_required(req):
+            return next()
+
+        if _is_no_auth_test_call_required(req):
+            req.context.set_authorize_result(
+                AuthorizeResult(
+                    enterprise_id=req.context.enterprise_id,
+                    team_id=req.context.team_id,
+                    user_id=req.context.user_id,
+                )
+            )
+            return next()
+
+        try:
+            if not self.auth_test_result:
+                self.auth_test_result = req.context.client.auth_test()
+
+            if self.auth_test_result:
+                req.context.set_authorize_result(
+                    _to_authorize_result(
+                        auth_test_result=self.auth_test_result,
+                        token=req.context.client.token,
+                        request_user_id=req.context.user_id,
+                    )
+                )
+                return next()
+            else:
+                # Just in case
+                self.logger.error("auth.test API call result is unexpectedly None")
+                if req.context.response_url is not None:
+                    req.context.respond(self.user_facing_authorize_error_message)  # type: ignore[misc]
+                    return BoltResponse(status=200, body="")
+                return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+        except SlackApiError as e:
+            self.logger.error(f"Failed to authorize with the given token ({e})")
+            return _build_user_facing_error_response(self.user_facing_authorize_error_message)
+
+

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

+

Single-workspace authorization.

+

Args

+
+
auth_test_result
+
The initial auth.test API call result.
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+class SslCheck +(verification_token: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class SslCheck(Middleware):
+    verification_token: Optional[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        verification_token: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """Handles `ssl_check` requests.
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.
+
+        Args:
+            verification_token: The verification token to check
+                (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+            base_logger: The base logger
+        """  # noqa: E501
+        self.verification_token = verification_token
+        self.logger = get_bolt_logger(SslCheck, base_logger=base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._is_ssl_check_request(req.body):
+            if self._verify_token_if_needed(req.body):
+                return self._build_error_response()
+            return self._build_success_response()
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _is_ssl_check_request(body: dict):
+        return "ssl_check" in body and body["ssl_check"] == "1"
+
+    def _verify_token_if_needed(self, body: dict):
+        return self.verification_token and self.verification_token == body["token"]
+
+    @staticmethod
+    def _build_success_response() -> BoltResponse:
+        return BoltResponse(status=200, body="")
+
+    @staticmethod
+    def _build_error_response() -> BoltResponse:
+        return BoltResponse(status=401, body={"error": "invalid verification token"})
+
+

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

+

Handles slack_bolt.middleware.ssl_check requests. +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

+

Args

+
+
verification_token
+
The verification token to check +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Subclasses

+ +

Class variables

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var verification_token : str | None
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+class UrlVerification +(base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class UrlVerification(Middleware):
+    def __init__(self, base_logger: Optional[Logger] = None):
+        """Handles url_verification requests.
+
+        Refer to https://docs.slack.dev/reference/events/url_verification/ for details.
+
+        Args:
+            base_logger: The base logger
+        """
+        self.logger = get_bolt_logger(UrlVerification, base_logger=base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._is_url_verification_request(req.body):
+            return self._build_success_response(req.body)
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _is_url_verification_request(body: dict) -> bool:
+        return body is not None and body.get("type") == "url_verification"
+
+    @staticmethod
+    def _build_success_response(body: dict) -> BoltResponse:
+        return BoltResponse(status=200, body={"challenge": body.get("challenge")})
+
+

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

+

Handles url_verification requests.

+

Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

+

Args

+
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html b/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html new file mode 100644 index 000000000..9cbee09ca --- /dev/null +++ b/docs/reference/middleware/message_listener_matches/async_message_listener_matches.html @@ -0,0 +1,130 @@ + + + + + + +slack_bolt.middleware.message_listener_matches.async_message_listener_matches API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.message_listener_matches.async_message_listener_matches

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncMessageListenerMatches +(keyword: str | Pattern) +
+
+
+ +Expand source code + +
class AsyncMessageListenerMatches(AsyncMiddleware):
+    def __init__(self, keyword: Union[str, Pattern]):
+        """Captures matched keywords and saves the values in context."""
+        self.keyword = keyword
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        text = req.body.get("event", {}).get("text", "")
+        if text:
+            m: Optional[Union[Sequence]] = re.findall(self.keyword, text)
+            if m is not None and m != []:
+                if type(m[0]) is not tuple:
+                    m = tuple(m)
+                else:
+                    m = m[0]
+                req.context["matches"] = m  # tuple or list
+                return await next()
+
+        # As the text doesn't match, skip running the listener
+        return resp
+
+

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

+

Captures matched keywords and saves the values in context.

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/message_listener_matches/index.html b/docs/reference/middleware/message_listener_matches/index.html new file mode 100644 index 000000000..29dfbb861 --- /dev/null +++ b/docs/reference/middleware/message_listener_matches/index.html @@ -0,0 +1,147 @@ + + + + + + +slack_bolt.middleware.message_listener_matches API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.message_listener_matches

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.message_listener_matches.async_message_listener_matches
+
+
+
+
slack_bolt.middleware.message_listener_matches.message_listener_matches
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class MessageListenerMatches +(keyword: str | Pattern) +
+
+
+ +Expand source code + +
class MessageListenerMatches(Middleware):
+    def __init__(self, keyword: Union[str, Pattern]):
+        """Captures matched keywords and saves the values in context."""
+        self.keyword = keyword
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        text = req.body.get("event", {}).get("text", "")
+        if text:
+            m: Optional[Union[Sequence]] = re.findall(self.keyword, text)
+            if m is not None and m != []:
+                if type(m[0]) is not tuple:
+                    m = tuple(m)
+                else:
+                    m = m[0]
+                req.context["matches"] = m  # tuple or list
+                return next()
+
+        # As the text doesn't match, skip running the listener
+        return resp
+
+

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

+

Captures matched keywords and saves the values in context.

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/message_listener_matches/message_listener_matches.html b/docs/reference/middleware/message_listener_matches/message_listener_matches.html new file mode 100644 index 000000000..35b5bfa7a --- /dev/null +++ b/docs/reference/middleware/message_listener_matches/message_listener_matches.html @@ -0,0 +1,130 @@ + + + + + + +slack_bolt.middleware.message_listener_matches.message_listener_matches API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.message_listener_matches.message_listener_matches

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class MessageListenerMatches +(keyword: str | Pattern) +
+
+
+ +Expand source code + +
class MessageListenerMatches(Middleware):
+    def __init__(self, keyword: Union[str, Pattern]):
+        """Captures matched keywords and saves the values in context."""
+        self.keyword = keyword
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        text = req.body.get("event", {}).get("text", "")
+        if text:
+            m: Optional[Union[Sequence]] = re.findall(self.keyword, text)
+            if m is not None and m != []:
+                if type(m[0]) is not tuple:
+                    m = tuple(m)
+                else:
+                    m = m[0]
+                req.context["matches"] = m  # tuple or list
+                return next()
+
+        # As the text doesn't match, skip running the listener
+        return resp
+
+

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

+

Captures matched keywords and saves the values in context.

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/middleware.html b/docs/reference/middleware/middleware.html new file mode 100644 index 000000000..efa8e6c30 --- /dev/null +++ b/docs/reference/middleware/middleware.html @@ -0,0 +1,239 @@ + + + + + + +slack_bolt.middleware.middleware API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.middleware

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Middleware +
+
+
+ +Expand source code + +
class Middleware(metaclass=ABCMeta):
+    """A middleware can process request data before other middleware and listener functions."""
+
+    @abstractmethod
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> Optional[BoltResponse]:
+        """Processes a request data before other middleware and listeners.
+        A middleware calls `next()` function if the chain should continue.
+
+            @app.middleware
+            def simple_middleware(req, resp, next):
+                # do something here
+                next()
+
+        This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python.
+        If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead.
+
+            @app.middleware
+            def simple_middleware(req, resp, next_):
+                # do something here
+                next_()
+
+        Args:
+            req: The incoming request
+            resp: The response
+            next: The function to tell the chain that it can continue
+
+        Returns:
+            Processed response (optional)
+        """
+        raise NotImplementedError()
+
+    @property
+    def name(self) -> str:
+        """The name of this middleware"""
+        return f"{self.__module__}.{self.__class__.__name__}"
+
+

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

+

Subclasses

+ +

Instance variables

+
+
prop name : str
+
+
+ +Expand source code + +
@property
+def name(self) -> str:
+    """The name of this middleware"""
+    return f"{self.__module__}.{self.__class__.__name__}"
+
+

The name of this middleware

+
+
+

Methods

+
+
+def process(self,
*,
req: BoltRequest,
resp: BoltResponse,
next: Callable[[], BoltResponse]) ‑> BoltResponse | None
+
+
+
+ +Expand source code + +
@abstractmethod
+def process(
+    self,
+    *,
+    req: BoltRequest,
+    resp: BoltResponse,
+    # As this method is not supposed to be invoked by bolt-python users,
+    # the naming conflict with the built-in one affects
+    # only the internals of this method
+    next: Callable[[], BoltResponse],
+) -> Optional[BoltResponse]:
+    """Processes a request data before other middleware and listeners.
+    A middleware calls `next()` function if the chain should continue.
+
+        @app.middleware
+        def simple_middleware(req, resp, next):
+            # do something here
+            next()
+
+    This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python.
+    If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead.
+
+        @app.middleware
+        def simple_middleware(req, resp, next_):
+            # do something here
+            next_()
+
+    Args:
+        req: The incoming request
+        resp: The response
+        next: The function to tell the chain that it can continue
+
+    Returns:
+        Processed response (optional)
+    """
+    raise NotImplementedError()
+
+

Processes a request data before other middleware and listeners. +A middleware calls next() function if the chain should continue.

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

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

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

Args

+
+
req
+
The incoming request
+
resp
+
The response
+
next
+
The function to tell the chain that it can continue
+
+

Returns

+

Processed response (optional)

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/middleware_error_handler.html b/docs/reference/middleware/middleware_error_handler.html new file mode 100644 index 000000000..1c5319feb --- /dev/null +++ b/docs/reference/middleware/middleware_error_handler.html @@ -0,0 +1,241 @@ + + + + + + +slack_bolt.middleware.middleware_error_handler API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.middleware_error_handler

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CustomMiddlewareErrorHandler +(logger: logging.Logger,
func: Callable[..., BoltResponse | None])
+
+
+
+ +Expand source code + +
class CustomMiddlewareErrorHandler(MiddlewareErrorHandler):
+    def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]):
+        self.func = func
+        self.logger = logger
+        self.arg_names = get_arg_names_of_callable(func)
+
+    def handle(
+        self,
+        error: Exception,
+        request: BoltRequest,
+        response: Optional[BoltResponse],
+    ):
+        kwargs: Dict[str, Any] = build_required_kwargs(
+            required_arg_names=self.arg_names,
+            logger=self.logger,
+            error=error,
+            request=request,
+            response=response,
+            next_keys_required=False,
+        )
+        returned_response = self.func(**kwargs)
+        if returned_response is not None and isinstance(returned_response, BoltResponse):
+            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

+ +

Inherited members

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

Ancestors

+ +

Inherited members

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

Subclasses

+ +

Methods

+
+
+def handle(self,
error: Exception,
request: BoltRequest,
response: BoltResponse | None) ‑> None
+
+
+
+ +Expand source code + +
@abstractmethod
+def handle(
+    self,
+    error: Exception,
+    request: BoltRequest,
+    response: Optional[BoltResponse],  # TODO: why is this optional
+) -> None:
+    """Handles an unhandled exception.
+
+    Args:
+        error: The raised exception.
+        request: The request.
+        response: The response.
+    """
+    raise NotImplementedError()
+
+

Handles an unhandled exception.

+

Args

+
+
error
+
The raised exception.
+
request
+
The request.
+
response
+
The response.
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/request_verification/async_request_verification.html b/docs/reference/middleware/request_verification/async_request_verification.html new file mode 100644 index 000000000..192f77933 --- /dev/null +++ b/docs/reference/middleware/request_verification/async_request_verification.html @@ -0,0 +1,148 @@ + + + + + + +slack_bolt.middleware.request_verification.async_request_verification API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.request_verification.async_request_verification

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncRequestVerification +(signing_secret: str, base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class AsyncRequestVerification(RequestVerification, AsyncMiddleware):
+    """Verifies an incoming request by checking the validity of
+    `x-slack-signature`, `x-slack-request-timestamp`, and its body data.
+
+    Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.
+    """
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if self._can_skip(req.mode, req.body):
+            return await next()
+
+        body = req.raw_body
+        timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0]
+        signature = req.headers.get("x-slack-signature", [""])[0]
+        if self.verifier.is_valid(body, timestamp, signature):
+            return await next()
+        else:
+            self._debug_log_error(signature, timestamp, body)
+            return self._build_error_response()
+
+

Verifies an incoming request by checking the validity of +x-slack-signature, x-slack-request-timestamp, and its body data.

+

Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

+

Verifies an incoming request by checking the validity of +x-slack-signature, x-slack-request-timestamp, and its body data.

+

Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

+

Args

+
+
signing_secret
+
The signing secret
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/request_verification/index.html b/docs/reference/middleware/request_verification/index.html new file mode 100644 index 000000000..50a8676b5 --- /dev/null +++ b/docs/reference/middleware/request_verification/index.html @@ -0,0 +1,211 @@ + + + + + + +slack_bolt.middleware.request_verification API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.request_verification

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.request_verification.async_request_verification
+
+
+
+
slack_bolt.middleware.request_verification.request_verification
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class RequestVerification +(signing_secret: str, base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class RequestVerification(Middleware):
+    def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None):
+        """Verifies an incoming request by checking the validity of
+        `x-slack-signature`, `x-slack-request-timestamp`, and its body data.
+
+        Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.
+
+        Args:
+            signing_secret: The signing secret
+            base_logger: The base logger
+        """
+        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,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._can_skip(req.mode, req.body):
+            return next()
+
+        body = req.raw_body
+        timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0]
+        signature = req.headers.get("x-slack-signature", [""])[0]
+        if self.verifier.is_valid(body, timestamp, signature):
+            return next()
+        else:
+            self._debug_log_error(signature, timestamp, body)
+            return self._build_error_response()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _can_skip(mode: str, body: Dict[str, Any]) -> bool:
+        return mode == "socket_mode"
+
+    @staticmethod
+    def _build_error_response() -> BoltResponse:
+        return BoltResponse(status=401, body={"error": "invalid request"})
+
+    def _debug_log_error(self, signature, timestamp, body) -> None:
+        self.logger.info(
+            "Invalid request signature detected " f"(signature: {signature}, timestamp: {timestamp}, body: {body})"
+        )
+
+

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

+

Verifies an incoming request by checking the validity of +x-slack-signature, x-slack-request-timestamp, and its body data.

+

Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

+

Args

+
+
signing_secret
+
The signing secret
+
base_logger
+
The base logger
+
+

Ancestors

+ +

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

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/request_verification/request_verification.html b/docs/reference/middleware/request_verification/request_verification.html new file mode 100644 index 000000000..4ee2ed1b1 --- /dev/null +++ b/docs/reference/middleware/request_verification/request_verification.html @@ -0,0 +1,194 @@ + + + + + + +slack_bolt.middleware.request_verification.request_verification API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.request_verification.request_verification

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class RequestVerification +(signing_secret: str, base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class RequestVerification(Middleware):
+    def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None):
+        """Verifies an incoming request by checking the validity of
+        `x-slack-signature`, `x-slack-request-timestamp`, and its body data.
+
+        Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.
+
+        Args:
+            signing_secret: The signing secret
+            base_logger: The base logger
+        """
+        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,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._can_skip(req.mode, req.body):
+            return next()
+
+        body = req.raw_body
+        timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0]
+        signature = req.headers.get("x-slack-signature", [""])[0]
+        if self.verifier.is_valid(body, timestamp, signature):
+            return next()
+        else:
+            self._debug_log_error(signature, timestamp, body)
+            return self._build_error_response()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _can_skip(mode: str, body: Dict[str, Any]) -> bool:
+        return mode == "socket_mode"
+
+    @staticmethod
+    def _build_error_response() -> BoltResponse:
+        return BoltResponse(status=401, body={"error": "invalid request"})
+
+    def _debug_log_error(self, signature, timestamp, body) -> None:
+        self.logger.info(
+            "Invalid request signature detected " f"(signature: {signature}, timestamp: {timestamp}, body: {body})"
+        )
+
+

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

+

Verifies an incoming request by checking the validity of +x-slack-signature, x-slack-request-timestamp, and its body data.

+

Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details.

+

Args

+
+
signing_secret
+
The signing secret
+
base_logger
+
The base logger
+
+

Ancestors

+ +

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

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/ssl_check/async_ssl_check.html b/docs/reference/middleware/ssl_check/async_ssl_check.html new file mode 100644 index 000000000..48c4bb599 --- /dev/null +++ b/docs/reference/middleware/ssl_check/async_ssl_check.html @@ -0,0 +1,137 @@ + + + + + + +slack_bolt.middleware.ssl_check.async_ssl_check API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.ssl_check.async_ssl_check

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncSslCheck +(verification_token: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncSslCheck(SslCheck, AsyncMiddleware):
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if self._is_ssl_check_request(req.body):
+            if self._verify_token_if_needed(req.body):
+                return self._build_error_response()
+            return self._build_success_response()
+        else:
+            return await next()
+
+

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

+

Handles ssl_check requests. +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

+

Args

+
+
verification_token
+
The verification token to check +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/ssl_check/index.html b/docs/reference/middleware/ssl_check/index.html new file mode 100644 index 000000000..6c1e4725e --- /dev/null +++ b/docs/reference/middleware/ssl_check/index.html @@ -0,0 +1,200 @@ + + + + + + +slack_bolt.middleware.ssl_check API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.ssl_check

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.ssl_check.async_ssl_check
+
+
+
+
slack_bolt.middleware.ssl_check.ssl_check
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SslCheck +(verification_token: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class SslCheck(Middleware):
+    verification_token: Optional[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        verification_token: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """Handles `ssl_check` requests.
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.
+
+        Args:
+            verification_token: The verification token to check
+                (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+            base_logger: The base logger
+        """  # noqa: E501
+        self.verification_token = verification_token
+        self.logger = get_bolt_logger(SslCheck, base_logger=base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._is_ssl_check_request(req.body):
+            if self._verify_token_if_needed(req.body):
+                return self._build_error_response()
+            return self._build_success_response()
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _is_ssl_check_request(body: dict):
+        return "ssl_check" in body and body["ssl_check"] == "1"
+
+    def _verify_token_if_needed(self, body: dict):
+        return self.verification_token and self.verification_token == body["token"]
+
+    @staticmethod
+    def _build_success_response() -> BoltResponse:
+        return BoltResponse(status=200, body="")
+
+    @staticmethod
+    def _build_error_response() -> BoltResponse:
+        return BoltResponse(status=401, body={"error": "invalid verification token"})
+
+

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

+

Handles slack_bolt.middleware.ssl_check.ssl_check requests. +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

+

Args

+
+
verification_token
+
The verification token to check +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Subclasses

+ +

Class variables

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var verification_token : str | None
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/ssl_check/ssl_check.html b/docs/reference/middleware/ssl_check/ssl_check.html new file mode 100644 index 000000000..f90ad4d87 --- /dev/null +++ b/docs/reference/middleware/ssl_check/ssl_check.html @@ -0,0 +1,183 @@ + + + + + + +slack_bolt.middleware.ssl_check.ssl_check API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.ssl_check.ssl_check

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class SslCheck +(verification_token: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class SslCheck(Middleware):
+    verification_token: Optional[str]
+    logger: Logger
+
+    def __init__(
+        self,
+        verification_token: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """Handles `ssl_check` requests.
+        Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.
+
+        Args:
+            verification_token: The verification token to check
+                (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+            base_logger: The base logger
+        """  # noqa: E501
+        self.verification_token = verification_token
+        self.logger = get_bolt_logger(SslCheck, base_logger=base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._is_ssl_check_request(req.body):
+            if self._verify_token_if_needed(req.body):
+                return self._build_error_response()
+            return self._build_success_response()
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _is_ssl_check_request(body: dict):
+        return "ssl_check" in body and body["ssl_check"] == "1"
+
+    def _verify_token_if_needed(self, body: dict):
+        return self.verification_token and self.verification_token == body["token"]
+
+    @staticmethod
+    def _build_success_response() -> BoltResponse:
+        return BoltResponse(status=200, body="")
+
+    @staticmethod
+    def _build_error_response() -> BoltResponse:
+        return BoltResponse(status=401, body={"error": "invalid verification token"})
+
+

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

+

Handles ssl_check requests. +Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details.

+

Args

+
+
verification_token
+
The verification token to check +(optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation)
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Subclasses

+ +

Class variables

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var verification_token : str | None
+
+

The type of the None singleton.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/url_verification/async_url_verification.html b/docs/reference/middleware/url_verification/async_url_verification.html new file mode 100644 index 000000000..d1408052d --- /dev/null +++ b/docs/reference/middleware/url_verification/async_url_verification.html @@ -0,0 +1,130 @@ + + + + + + +slack_bolt.middleware.url_verification.async_url_verification API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.url_verification.async_url_verification

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncUrlVerification +(base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class AsyncUrlVerification(UrlVerification, AsyncMiddleware):
+    def __init__(self, base_logger: Optional[Logger] = None):
+        self.logger = get_bolt_logger(AsyncUrlVerification, base_logger=base_logger)
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+        if self._is_url_verification_request(req.body):
+            return self._build_success_response(req.body)
+        else:
+            return await next()
+
+

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

+

Handles url_verification requests.

+

Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

+

Args

+
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/url_verification/index.html b/docs/reference/middleware/url_verification/index.html new file mode 100644 index 000000000..480c861d6 --- /dev/null +++ b/docs/reference/middleware/url_verification/index.html @@ -0,0 +1,164 @@ + + + + + + +slack_bolt.middleware.url_verification API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.url_verification

+
+
+
+
+

Sub-modules

+
+
slack_bolt.middleware.url_verification.async_url_verification
+
+
+
+
slack_bolt.middleware.url_verification.url_verification
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class UrlVerification +(base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class UrlVerification(Middleware):
+    def __init__(self, base_logger: Optional[Logger] = None):
+        """Handles url_verification requests.
+
+        Refer to https://docs.slack.dev/reference/events/url_verification/ for details.
+
+        Args:
+            base_logger: The base logger
+        """
+        self.logger = get_bolt_logger(UrlVerification, base_logger=base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._is_url_verification_request(req.body):
+            return self._build_success_response(req.body)
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _is_url_verification_request(body: dict) -> bool:
+        return body is not None and body.get("type") == "url_verification"
+
+    @staticmethod
+    def _build_success_response(body: dict) -> BoltResponse:
+        return BoltResponse(status=200, body={"challenge": body.get("challenge")})
+
+

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

+

Handles url_verification requests.

+

Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

+

Args

+
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/middleware/url_verification/url_verification.html b/docs/reference/middleware/url_verification/url_verification.html new file mode 100644 index 000000000..ff22c2986 --- /dev/null +++ b/docs/reference/middleware/url_verification/url_verification.html @@ -0,0 +1,147 @@ + + + + + + +slack_bolt.middleware.url_verification.url_verification API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.middleware.url_verification.url_verification

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class UrlVerification +(base_logger: logging.Logger | None = None) +
+
+
+ +Expand source code + +
class UrlVerification(Middleware):
+    def __init__(self, base_logger: Optional[Logger] = None):
+        """Handles url_verification requests.
+
+        Refer to https://docs.slack.dev/reference/events/url_verification/ for details.
+
+        Args:
+            base_logger: The base logger
+        """
+        self.logger = get_bolt_logger(UrlVerification, base_logger=base_logger)
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> BoltResponse:
+        if self._is_url_verification_request(req.body):
+            return self._build_success_response(req.body)
+        else:
+            return next()
+
+    # -----------------------------------------
+
+    @staticmethod
+    def _is_url_verification_request(body: dict) -> bool:
+        return body is not None and body.get("type") == "url_verification"
+
+    @staticmethod
+    def _build_success_response(body: dict) -> BoltResponse:
+        return BoltResponse(status=200, body={"challenge": body.get("challenge")})
+
+

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

+

Handles url_verification requests.

+

Refer to https://docs.slack.dev/reference/events/url_verification/ for details.

+

Args

+
+
base_logger
+
The base logger
+
+

Ancestors

+ +

Subclasses

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/async_callback_options.html b/docs/reference/oauth/async_callback_options.html new file mode 100644 index 000000000..d07f1aee5 --- /dev/null +++ b/docs/reference/oauth/async_callback_options.html @@ -0,0 +1,285 @@ + + + + + + +slack_bolt.oauth.async_callback_options API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.async_callback_options

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncCallbackOptions +(success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]],
failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]])
+
+
+
+ +Expand source code + +
class AsyncCallbackOptions:
+    success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]]
+    failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]]
+
+    def __init__(
+        self,
+        success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]],
+        failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]],
+    ):
+        self.success = success
+        self.failure = failure
+
+
+

Subclasses

+ +

Class variables

+
+
var failure : Callable[[AsyncFailureArgs], Awaitable[BoltResponse]]
+
+

The type of the None singleton.

+
+
var success : Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]]
+
+

The type of the None singleton.

+
+
+
+
+class AsyncFailureArgs +(*,
request: AsyncBoltRequest,
reason: str,
error: Exception | None = None,
suggested_status_code: int,
settings: AsyncOAuthSettings,
default: AsyncCallbackOptions)
+
+
+
+ +Expand source code + +
class AsyncFailureArgs:
+    def __init__(
+        self,
+        *,
+        request: AsyncBoltRequest,
+        reason: str,
+        error: Optional[Exception] = None,
+        suggested_status_code: int,
+        settings: "AsyncOAuthSettings",
+        default: "AsyncCallbackOptions",
+    ):
+        """The arguments for a failure function.
+
+        Args:
+            request: The request.
+            reason: The response.
+            error: An exception if exists.
+            suggested_status_code: The recommended HTTP status code for the failure.
+            settings: The settings for Slack OAuth flow.
+            default: The default `AsyncCallbackOptions`.
+        """
+        self.request = request
+        self.reason = reason
+        self.error = error
+        self.suggested_status_code = suggested_status_code
+        self.settings = settings
+        self.default = default
+
+

The arguments for a failure function.

+

Args

+
+
request
+
The request.
+
reason
+
The response.
+
error
+
An exception if exists.
+
suggested_status_code
+
The recommended HTTP status code for the failure.
+
settings
+
The settings for Slack OAuth flow.
+
default
+
The default AsyncCallbackOptions.
+
+
+
+class AsyncSuccessArgs +(*,
request: AsyncBoltRequest,
installation: slack_sdk.oauth.installation_store.models.installation.Installation,
settings: AsyncOAuthSettings,
default: AsyncCallbackOptions)
+
+
+
+ +Expand source code + +
class AsyncSuccessArgs:
+    def __init__(
+        self,
+        *,
+        request: AsyncBoltRequest,
+        installation: Installation,
+        settings: "AsyncOAuthSettings",
+        default: "AsyncCallbackOptions",
+    ):
+        """The arguments for a success function.
+
+        Args:
+            request: The request.
+            installation: The installation data.
+            settings: The settings for Slack OAuth flow.
+            default: The default `AsyncCallbackOptions`.
+        """
+        self.request = request
+        self.installation = installation
+        self.settings = settings
+        self.default = default
+
+

The arguments for a success function.

+

Args

+
+
request
+
The request.
+
installation
+
The installation data.
+
settings
+
The settings for Slack OAuth flow.
+
default
+
The default AsyncCallbackOptions.
+
+
+
+class DefaultAsyncCallbackOptions +(*,
logger: logging.Logger,
state_utils: slack_sdk.oauth.state_utils.OAuthStateUtils,
redirect_uri_page_renderer: slack_sdk.oauth.redirect_uri_page_renderer.RedirectUriPageRenderer)
+
+
+
+ +Expand source code + +
class DefaultAsyncCallbackOptions(AsyncCallbackOptions):
+    success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]]
+    failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]]
+
+    def __init__(
+        self,
+        *,
+        logger: Logger,
+        state_utils: OAuthStateUtils,
+        redirect_uri_page_renderer: RedirectUriPageRenderer,
+    ):
+        self._response_builder = CallbackResponseBuilder(
+            logger=logger or logging.getLogger(__name__),
+            state_utils=state_utils,
+            redirect_uri_page_renderer=redirect_uri_page_renderer,
+        )
+        self.success = self._success_handler
+        self.failure = self._failure_handler
+
+    # --------------------------
+    # Internal methods
+    # --------------------------
+
+    async def _success_handler(self, args: AsyncSuccessArgs) -> BoltResponse:
+        return self._response_builder._build_callback_success_response(
+            request=args.request,
+            installation=args.installation,
+        )
+
+    async def _failure_handler(self, args: AsyncFailureArgs) -> BoltResponse:
+        return self._response_builder._build_callback_failure_response(
+            request=args.request,
+            reason=args.reason,
+            status=args.suggested_status_code,
+        )
+
+
+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/async_internals.html b/docs/reference/oauth/async_internals.html new file mode 100644 index 000000000..2b35a69c9 --- /dev/null +++ b/docs/reference/oauth/async_internals.html @@ -0,0 +1,126 @@ + + + + + + +slack_bolt.oauth.async_internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.async_internals

+
+
+
+
+
+
+
+
+

Functions

+
+
+def get_or_create_default_installation_store(client_id: str) ‑> slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore +
+
+
+ +Expand source code + +
def get_or_create_default_installation_store(client_id: str) -> AsyncInstallationStore:
+    store = default_installation_stores.get(client_id)
+    if store is None:
+        store = FileInstallationStore(client_id=client_id)
+        default_installation_stores[client_id] = store
+    return store
+
+
+
+
+def select_consistent_installation_store(client_id: str,
app_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None,
oauth_flow_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None,
logger: logging.Logger) ‑> slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None
+
+
+
+ +Expand source code + +
def select_consistent_installation_store(
+    client_id: str,
+    app_store: Optional[AsyncInstallationStore],
+    oauth_flow_store: Optional[AsyncInstallationStore],
+    logger: Logger,
+) -> Optional[AsyncInstallationStore]:
+    default = get_or_create_default_installation_store(client_id)
+    if app_store is not None:
+        if oauth_flow_store is not None:
+            if oauth_flow_store is default:
+                # only app_store is intentionally set in this case
+                return app_store
+
+            # if both are intentionally set, prioritize app_store
+            if oauth_flow_store is not app_store:
+                logger.warning(warning_installation_store_conflicts())
+            return oauth_flow_store
+        else:
+            # only app_store is available
+            return app_store
+    else:
+        # only oauth_flow_store is available
+        return oauth_flow_store
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/async_oauth_flow.html b/docs/reference/oauth/async_oauth_flow.html new file mode 100644 index 000000000..3ccdfd6f0 --- /dev/null +++ b/docs/reference/oauth/async_oauth_flow.html @@ -0,0 +1,809 @@ + + + + + + +slack_bolt.oauth.async_oauth_flow API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.async_oauth_flow

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncOAuthFlow +(*,
client: slack_sdk.web.async_client.AsyncWebClient | None = None,
logger: logging.Logger | None = None,
settings: AsyncOAuthSettings)
+
+
+
+ +Expand source code + +
class AsyncOAuthFlow:
+    settings: AsyncOAuthSettings
+    client_id: str
+    redirect_uri: Optional[str]
+    install_path: str
+    redirect_uri_path: str
+
+    success_handler: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]]
+    failure_handler: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]]
+
+    def __init__(
+        self,
+        *,
+        client: Optional[AsyncWebClient] = None,
+        logger: Optional[Logger] = None,
+        settings: AsyncOAuthSettings,
+    ):
+        """The module to run the Slack app installation flow (OAuth flow).
+
+        Args:
+            client: The `slack_sdk.web.async_client.AsyncWebClient` instance.
+            logger: The logger.
+            settings: OAuth settings to configure this module.
+        """
+        self._async_client = client
+        self._logger = logger
+
+        if not isinstance(settings, AsyncOAuthSettings):
+            raise BoltError(error_oauth_settings_invalid_type_async())
+        self.settings = settings
+
+        if self._logger is not None:
+            self.settings.logger = self._logger
+
+        self.client_id = self.settings.client_id
+        self.redirect_uri = self.settings.redirect_uri
+        self.install_path = self.settings.install_path
+        self.redirect_uri_path = self.settings.redirect_uri_path
+
+        self.default_callback_options = DefaultAsyncCallbackOptions(
+            logger=logger,  # type: ignore[arg-type]
+            state_utils=self.settings.state_utils,
+            redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer,
+        )
+        if settings.callback_options is None:
+            settings.callback_options = self.default_callback_options
+        self.success_handler = settings.callback_options.success
+        self.failure_handler = settings.callback_options.failure
+
+    @property
+    def client(self) -> AsyncWebClient:
+        if self._async_client is None:
+            self._async_client = create_async_web_client(logger=self.logger)
+        return self._async_client
+
+    @property
+    def logger(self) -> Logger:
+        if self._logger is None:
+            self._logger = logging.getLogger(__name__)
+        return self._logger
+
+    # -----------------------------
+    # Factory Methods
+    # -----------------------------
+
+    @classmethod
+    def sqlite3(
+        cls,
+        database: str,
+        # OAuth flow parameters/credentials
+        authorization_url: Optional[str] = None,
+        client_id: Optional[str] = None,  # required
+        client_secret: Optional[str] = None,  # required
+        scopes: Optional[Sequence[str]] = None,
+        user_scopes: Optional[Sequence[str]] = None,
+        redirect_uri: Optional[str] = None,
+        # Handler configuration
+        install_path: Optional[str] = None,
+        redirect_uri_path: Optional[str] = None,
+        callback_options: Optional[AsyncCallbackOptions] = None,
+        success_url: Optional[str] = None,
+        failure_url: Optional[str] = None,
+        # Installation Management
+        # state parameter related configurations
+        state_cookie_name: str = OAuthStateUtils.default_cookie_name,
+        state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds,
+        installation_store_bot_only: bool = False,
+        client: Optional[AsyncWebClient] = None,
+        logger: Optional[Logger] = None,
+    ) -> "AsyncOAuthFlow":
+
+        client_id = client_id or os.environ["SLACK_CLIENT_ID"]  # required
+        client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"]  # required
+        scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",")
+        user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",")
+        redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI")
+        installation_store = (
+            SQLite3InstallationStore(database=database, client_id=client_id)
+            if logger is None
+            else SQLite3InstallationStore(database=database, client_id=client_id, logger=logger)
+        )
+        state_store = (
+            SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds)
+            if logger is None
+            else SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds, logger=logger)
+        )
+        return AsyncOAuthFlow(
+            client=client or AsyncWebClient(),
+            logger=logger,
+            settings=AsyncOAuthSettings(
+                # OAuth flow parameters/credentials
+                authorization_url=authorization_url,
+                client_id=client_id,
+                client_secret=client_secret,
+                scopes=scopes,
+                user_scopes=user_scopes,
+                redirect_uri=redirect_uri,
+                # Handler configuration
+                install_path=install_path,  # type: ignore[arg-type]
+                redirect_uri_path=redirect_uri_path,  # type: ignore[arg-type]
+                callback_options=callback_options,
+                success_url=success_url,
+                failure_url=failure_url,
+                # Installation Management
+                installation_store=installation_store,
+                installation_store_bot_only=installation_store_bot_only,
+                # state parameter related configurations
+                state_store=state_store,
+                state_cookie_name=state_cookie_name,
+                state_expiration_seconds=state_expiration_seconds,
+            ),
+        )
+
+    # -----------------------------
+    # Installation
+    # -----------------------------
+
+    async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse:
+        set_cookie_value: Optional[str] = None
+        url = await self.build_authorize_url("", request)
+        if self.settings.state_validation_enabled is True:
+            state = await self.issue_new_state(request)
+            url = await self.build_authorize_url(state, request)
+            set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state)
+        if self.settings.install_page_rendering_enabled:
+            html = await self.build_install_page_html(url, request)
+            return BoltResponse(
+                status=200,
+                body=html,
+                headers=await self.append_set_cookie_headers(
+                    {"Content-Type": "text/html; charset=utf-8"},
+                    set_cookie_value,
+                ),
+            )
+        else:
+            return BoltResponse(
+                status=302,
+                body="",
+                headers=await self.append_set_cookie_headers(
+                    {"Content-Type": "text/html; charset=utf-8", "Location": url},
+                    set_cookie_value,
+                ),
+            )
+
+    # ----------------------
+    # Internal methods for Installation
+
+    async def issue_new_state(self, request: AsyncBoltRequest) -> str:
+        return await self.settings.state_store.async_issue()
+
+    async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str:
+        team_ids: Optional[Sequence[str]] = request.query.get("team")
+        return self.settings.authorize_url_generator.generate(
+            state=state,
+            team=team_ids[0] if team_ids is not None else None,
+        )
+
+    async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str:
+        return _build_default_install_page_html(url)
+
+    async def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]):
+        if set_cookie_value is not None:
+            headers["Set-Cookie"] = [set_cookie_value]
+        return headers
+
+    # -----------------------------
+    # Callback
+    # -----------------------------
+
+    async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse:
+
+        # failure due to end-user's cancellation or invalid redirection to slack.com
+        error = request.query.get("error", [None])[0]
+        if error is not None:
+            return await self.failure_handler(
+                AsyncFailureArgs(
+                    request=request,
+                    reason=error,
+                    suggested_status_code=200,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # state parameter verification
+        if self.settings.state_validation_enabled is True:
+            state: Optional[str] = request.query.get("state", [None])[0]
+            if not self.settings.state_utils.is_valid_browser(state, request.headers):
+                return await self.failure_handler(
+                    AsyncFailureArgs(
+                        request=request,
+                        reason="invalid_browser",
+                        suggested_status_code=400,
+                        settings=self.settings,
+                        default=self.default_callback_options,
+                    )
+                )
+
+            valid_state_consumed = await self.settings.state_store.async_consume(state)  # type: ignore[arg-type]
+            if not valid_state_consumed:
+                return await self.failure_handler(
+                    AsyncFailureArgs(
+                        request=request,
+                        reason="invalid_state",
+                        suggested_status_code=401,
+                        settings=self.settings,
+                        default=self.default_callback_options,
+                    )
+                )
+
+        # run installation
+        code = request.query.get("code", [None])[0]
+        if code is None:
+            return await self.failure_handler(
+                AsyncFailureArgs(
+                    request=request,
+                    reason="missing_code",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        installation = await self.run_installation(code)
+        if installation is None:
+            # failed to run installation with the code
+            return await self.failure_handler(
+                AsyncFailureArgs(
+                    request=request,
+                    reason="invalid_code",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # persist the installation
+        try:
+            await self.store_installation(request, installation)
+        except BoltError as err:
+            return await self.failure_handler(
+                AsyncFailureArgs(
+                    request=request,
+                    reason="storage_error",
+                    error=err,
+                    suggested_status_code=500,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # display a successful completion page to the end-user
+        return await self.success_handler(
+            AsyncSuccessArgs(
+                request=request,
+                installation=installation,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # ----------------------
+    # Internal methods for Callback
+
+    async def run_installation(self, code: str) -> Optional[Installation]:
+        try:
+            oauth_response: AsyncSlackResponse = await self.client.oauth_v2_access(
+                code=code,
+                client_id=self.settings.client_id,
+                client_secret=self.settings.client_secret,
+                redirect_uri=self.settings.redirect_uri,  # can be None
+            )
+            installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {}
+            is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False
+            installed_team: Dict[str, str] = oauth_response.get("team") or {}
+            installer: Dict[str, str] = oauth_response.get("authed_user") or {}
+            incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {}
+
+            bot_token: Optional[str] = oauth_response.get("access_token")
+            # NOTE: oauth.v2.access doesn't include bot_id in response
+            bot_id: Optional[str] = None
+            enterprise_url: Optional[str] = None
+            if bot_token is not None:
+                auth_test = await self.client.auth_test(token=bot_token)
+                bot_id = auth_test["bot_id"]
+            if is_enterprise_install is True:
+                enterprise_url = auth_test.get("url")
+
+            return Installation(
+                app_id=oauth_response.get("app_id"),
+                enterprise_id=installed_enterprise.get("id"),
+                enterprise_name=installed_enterprise.get("name"),
+                enterprise_url=enterprise_url,
+                team_id=installed_team.get("id"),
+                team_name=installed_team.get("name"),
+                bot_token=bot_token,
+                bot_id=bot_id,
+                bot_user_id=oauth_response.get("bot_user_id"),
+                bot_scopes=oauth_response.get("scope"),  # type: ignore[arg-type] # comma-separated string
+                bot_refresh_token=oauth_response.get("refresh_token"),  # since v1.7
+                bot_token_expires_in=oauth_response.get("expires_in"),  # since v1.7
+                user_id=installer.get("id"),  # type: ignore[arg-type]
+                user_token=installer.get("access_token"),
+                user_scopes=installer.get("scope"),  # type: ignore[arg-type]# comma-separated string
+                user_refresh_token=installer.get("refresh_token"),  # since v1.7
+                user_token_expires_in=installer.get("expires_in"),  # type: ignore[arg-type] # since v1.7
+                incoming_webhook_url=incoming_webhook.get("url"),
+                incoming_webhook_channel=incoming_webhook.get("channel"),
+                incoming_webhook_channel_id=incoming_webhook.get("channel_id"),
+                incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"),
+                is_enterprise_install=is_enterprise_install,
+                token_type=oauth_response.get("token_type"),
+            )
+
+        except SlackApiError as e:
+            message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}"
+            self.logger.warning(message)
+            return None
+
+    async def store_installation(self, request: AsyncBoltRequest, installation: Installation):
+        # may raise BoltError
+        await self.settings.installation_store.async_save(installation)
+
+

The module to run the Slack app installation flow (OAuth flow).

+

Args

+
+
client
+
The slack_sdk.web.async_client.AsyncWebClient instance.
+
logger
+
The logger.
+
settings
+
OAuth settings to configure this module.
+
+

Class variables

+
+
var client_id : str
+
+

The type of the None singleton.

+
+
var failure_handler : Callable[[AsyncFailureArgs], Awaitable[BoltResponse]]
+
+

The type of the None singleton.

+
+
var install_path : str
+
+

The type of the None singleton.

+
+
var redirect_uri : str | None
+
+

The type of the None singleton.

+
+
var redirect_uri_path : str
+
+

The type of the None singleton.

+
+
var settingsAsyncOAuthSettings
+
+

The type of the None singleton.

+
+
var success_handler : Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]]
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def sqlite3(database: str,
authorization_url: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
scopes: Sequence[str] | None = None,
user_scopes: Sequence[str] | None = None,
redirect_uri: str | None = None,
install_path: str | None = None,
redirect_uri_path: str | None = None,
callback_options: AsyncCallbackOptions | None = None,
success_url: str | None = None,
failure_url: str | None = None,
state_cookie_name: str = 'slack-app-oauth-state',
state_expiration_seconds: int = 600,
installation_store_bot_only: bool = False,
client: slack_sdk.web.async_client.AsyncWebClient | None = None,
logger: logging.Logger | None = None) ‑> AsyncOAuthFlow
+
+
+
+
+
+

Instance variables

+
+
prop client : slack_sdk.web.async_client.AsyncWebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> AsyncWebClient:
+    if self._async_client is None:
+        self._async_client = create_async_web_client(logger=self.logger)
+    return self._async_client
+
+
+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> Logger:
+    if self._logger is None:
+        self._logger = logging.getLogger(__name__)
+    return self._logger
+
+
+
+
+

Methods

+
+ +
+
+ +Expand source code + +
async def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]):
+    if set_cookie_value is not None:
+        headers["Set-Cookie"] = [set_cookie_value]
+    return headers
+
+
+
+
+async def build_authorize_url(self,
state: str,
request: AsyncBoltRequest) ‑> str
+
+
+
+ +Expand source code + +
async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str:
+    team_ids: Optional[Sequence[str]] = request.query.get("team")
+    return self.settings.authorize_url_generator.generate(
+        state=state,
+        team=team_ids[0] if team_ids is not None else None,
+    )
+
+
+
+
+async def build_install_page_html(self,
url: str,
request: AsyncBoltRequest) ‑> str
+
+
+
+ +Expand source code + +
async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str:
+    return _build_default_install_page_html(url)
+
+
+
+
+async def handle_callback(self,
request: AsyncBoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse:
+
+    # failure due to end-user's cancellation or invalid redirection to slack.com
+    error = request.query.get("error", [None])[0]
+    if error is not None:
+        return await self.failure_handler(
+            AsyncFailureArgs(
+                request=request,
+                reason=error,
+                suggested_status_code=200,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # state parameter verification
+    if self.settings.state_validation_enabled is True:
+        state: Optional[str] = request.query.get("state", [None])[0]
+        if not self.settings.state_utils.is_valid_browser(state, request.headers):
+            return await self.failure_handler(
+                AsyncFailureArgs(
+                    request=request,
+                    reason="invalid_browser",
+                    suggested_status_code=400,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        valid_state_consumed = await self.settings.state_store.async_consume(state)  # type: ignore[arg-type]
+        if not valid_state_consumed:
+            return await self.failure_handler(
+                AsyncFailureArgs(
+                    request=request,
+                    reason="invalid_state",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+    # run installation
+    code = request.query.get("code", [None])[0]
+    if code is None:
+        return await self.failure_handler(
+            AsyncFailureArgs(
+                request=request,
+                reason="missing_code",
+                suggested_status_code=401,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    installation = await self.run_installation(code)
+    if installation is None:
+        # failed to run installation with the code
+        return await self.failure_handler(
+            AsyncFailureArgs(
+                request=request,
+                reason="invalid_code",
+                suggested_status_code=401,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # persist the installation
+    try:
+        await self.store_installation(request, installation)
+    except BoltError as err:
+        return await self.failure_handler(
+            AsyncFailureArgs(
+                request=request,
+                reason="storage_error",
+                error=err,
+                suggested_status_code=500,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # display a successful completion page to the end-user
+    return await self.success_handler(
+        AsyncSuccessArgs(
+            request=request,
+            installation=installation,
+            settings=self.settings,
+            default=self.default_callback_options,
+        )
+    )
+
+
+
+
+async def handle_installation(self,
request: AsyncBoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse:
+    set_cookie_value: Optional[str] = None
+    url = await self.build_authorize_url("", request)
+    if self.settings.state_validation_enabled is True:
+        state = await self.issue_new_state(request)
+        url = await self.build_authorize_url(state, request)
+        set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state)
+    if self.settings.install_page_rendering_enabled:
+        html = await self.build_install_page_html(url, request)
+        return BoltResponse(
+            status=200,
+            body=html,
+            headers=await self.append_set_cookie_headers(
+                {"Content-Type": "text/html; charset=utf-8"},
+                set_cookie_value,
+            ),
+        )
+    else:
+        return BoltResponse(
+            status=302,
+            body="",
+            headers=await self.append_set_cookie_headers(
+                {"Content-Type": "text/html; charset=utf-8", "Location": url},
+                set_cookie_value,
+            ),
+        )
+
+
+
+
+async def issue_new_state(self,
request: AsyncBoltRequest) ‑> str
+
+
+
+ +Expand source code + +
async def issue_new_state(self, request: AsyncBoltRequest) -> str:
+    return await self.settings.state_store.async_issue()
+
+
+
+
+async def run_installation(self, code: str) ‑> slack_sdk.oauth.installation_store.models.installation.Installation | None +
+
+
+ +Expand source code + +
async def run_installation(self, code: str) -> Optional[Installation]:
+    try:
+        oauth_response: AsyncSlackResponse = await self.client.oauth_v2_access(
+            code=code,
+            client_id=self.settings.client_id,
+            client_secret=self.settings.client_secret,
+            redirect_uri=self.settings.redirect_uri,  # can be None
+        )
+        installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {}
+        is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False
+        installed_team: Dict[str, str] = oauth_response.get("team") or {}
+        installer: Dict[str, str] = oauth_response.get("authed_user") or {}
+        incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {}
+
+        bot_token: Optional[str] = oauth_response.get("access_token")
+        # NOTE: oauth.v2.access doesn't include bot_id in response
+        bot_id: Optional[str] = None
+        enterprise_url: Optional[str] = None
+        if bot_token is not None:
+            auth_test = await self.client.auth_test(token=bot_token)
+            bot_id = auth_test["bot_id"]
+        if is_enterprise_install is True:
+            enterprise_url = auth_test.get("url")
+
+        return Installation(
+            app_id=oauth_response.get("app_id"),
+            enterprise_id=installed_enterprise.get("id"),
+            enterprise_name=installed_enterprise.get("name"),
+            enterprise_url=enterprise_url,
+            team_id=installed_team.get("id"),
+            team_name=installed_team.get("name"),
+            bot_token=bot_token,
+            bot_id=bot_id,
+            bot_user_id=oauth_response.get("bot_user_id"),
+            bot_scopes=oauth_response.get("scope"),  # type: ignore[arg-type] # comma-separated string
+            bot_refresh_token=oauth_response.get("refresh_token"),  # since v1.7
+            bot_token_expires_in=oauth_response.get("expires_in"),  # since v1.7
+            user_id=installer.get("id"),  # type: ignore[arg-type]
+            user_token=installer.get("access_token"),
+            user_scopes=installer.get("scope"),  # type: ignore[arg-type]# comma-separated string
+            user_refresh_token=installer.get("refresh_token"),  # since v1.7
+            user_token_expires_in=installer.get("expires_in"),  # type: ignore[arg-type] # since v1.7
+            incoming_webhook_url=incoming_webhook.get("url"),
+            incoming_webhook_channel=incoming_webhook.get("channel"),
+            incoming_webhook_channel_id=incoming_webhook.get("channel_id"),
+            incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"),
+            is_enterprise_install=is_enterprise_install,
+            token_type=oauth_response.get("token_type"),
+        )
+
+    except SlackApiError as e:
+        message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}"
+        self.logger.warning(message)
+        return None
+
+
+
+
+async def store_installation(self,
request: AsyncBoltRequest,
installation: slack_sdk.oauth.installation_store.models.installation.Installation)
+
+
+
+ +Expand source code + +
async def store_installation(self, request: AsyncBoltRequest, installation: Installation):
+    # may raise BoltError
+    await self.settings.installation_store.async_save(installation)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/async_oauth_settings.html b/docs/reference/oauth/async_oauth_settings.html new file mode 100644 index 000000000..3b8c04edb --- /dev/null +++ b/docs/reference/oauth/async_oauth_settings.html @@ -0,0 +1,423 @@ + + + + + + +slack_bolt.oauth.async_oauth_settings API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.async_oauth_settings

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncOAuthSettings +(*,
client_id: str | None = None,
client_secret: str | None = None,
scopes: Sequence[str] | str | None = None,
user_scopes: Sequence[str] | str | None = None,
redirect_uri: str | None = None,
install_path: str = '/slack/install',
install_page_rendering_enabled: bool = True,
redirect_uri_path: str = '/slack/oauth_redirect',
callback_options: AsyncCallbackOptions | None = None,
success_url: str | None = None,
failure_url: str | None = None,
authorization_url: str | None = None,
installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
installation_store_bot_only: bool = False,
token_rotation_expiration_minutes: int = 120,
user_token_resolution: str = 'authed_user',
state_validation_enabled: bool = True,
state_store: slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore | None = None,
state_cookie_name: str = 'slack-app-oauth-state',
state_expiration_seconds: int = 600,
logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
+
+
+
+ +Expand source code + +
class AsyncOAuthSettings:
+    # OAuth flow parameters/credentials
+    client_id: str
+    client_secret: str
+    scopes: Optional[Sequence[str]]
+    user_scopes: Optional[Sequence[str]]
+    redirect_uri: Optional[str]
+    # Handler configuration
+    install_path: str
+    install_page_rendering_enabled: bool
+    redirect_uri_path: str
+    callback_options: Optional[AsyncCallbackOptions] = None
+    success_url: Optional[str]
+    failure_url: Optional[str]
+    authorization_url: str  # default: https://slack.com/oauth/v2/authorize
+    # Installation Management
+    installation_store: AsyncInstallationStore
+    installation_store_bot_only: bool
+    token_rotation_expiration_minutes: int
+    user_token_resolution: str
+    authorize: AsyncAuthorize
+    # state parameter related configurations
+    state_validation_enabled: bool
+    state_store: AsyncOAuthStateStore
+    state_cookie_name: str
+    state_expiration_seconds: int
+    # Customizable utilities
+    state_utils: OAuthStateUtils
+    authorize_url_generator: AuthorizeUrlGenerator
+    redirect_uri_page_renderer: RedirectUriPageRenderer
+    # Others
+    logger: Logger
+
+    def __init__(
+        self,
+        *,
+        # OAuth flow parameters/credentials
+        client_id: Optional[str] = None,  # required
+        client_secret: Optional[str] = None,  # required
+        scopes: Optional[Union[Sequence[str], str]] = None,
+        user_scopes: Optional[Union[Sequence[str], str]] = None,
+        redirect_uri: Optional[str] = None,
+        # Handler configuration
+        install_path: str = "/slack/install",
+        install_page_rendering_enabled: bool = True,
+        redirect_uri_path: str = "/slack/oauth_redirect",
+        callback_options: Optional[AsyncCallbackOptions] = None,
+        success_url: Optional[str] = None,
+        failure_url: Optional[str] = None,
+        authorization_url: Optional[str] = None,
+        # Installation Management
+        installation_store: Optional[AsyncInstallationStore] = None,
+        installation_store_bot_only: bool = False,
+        token_rotation_expiration_minutes: int = 120,
+        user_token_resolution: str = "authed_user",
+        # state parameter related configurations
+        state_validation_enabled: bool = True,
+        state_store: Optional[AsyncOAuthStateStore] = None,
+        state_cookie_name: str = OAuthStateUtils.default_cookie_name,
+        state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds,
+        # Others
+        logger: Logger = logging.getLogger(__name__),
+    ):
+        """The settings for Slack App installation (OAuth flow).
+
+        Args:
+            client_id: Check the value in Settings > Basic Information > App Credentials
+            client_secret: Check the value in Settings > Basic Information > App Credentials
+            scopes: Check the value in Settings > Manage Distribution
+            user_scopes: Check the value in Settings > Manage Distribution
+            redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs
+            install_path: The endpoint to start an OAuth flow (Default: `/slack/install`)
+            install_page_rendering_enabled: Renders a web page for install_path access if True
+            redirect_uri_path: The path of Redirect URL (Default: `/slack/oauth_redirect`)
+            callback_options: Give success/failure functions f you want to customize callback functions.
+            success_url: Set a complete URL if you want to redirect end-users when an installation completes.
+            failure_url: Set a complete URL if you want to redirect end-users when an installation fails.
+            authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize`
+            installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`)
+            installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False)
+            token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours)
+            user_token_resolution: The option to pick up a user token per request (Default: authed_user)
+                The available values are "authed_user" and "actor". When you want to resolve the user token per request
+                using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve
+                a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect
+                channels. Note that actor IDs can be absent in some scenarios.
+            state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True)
+            state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`)
+            state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state")
+            state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds)
+            logger: The logger that will be used internally
+        """
+        # OAuth flow parameters/credentials
+        client_id = client_id or os.environ.get("SLACK_CLIENT_ID")
+        client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET")
+        if client_id is None or client_secret is None:
+            raise BoltError("Both client_id and client_secret are required")
+        self.client_id = client_id
+        self.client_secret = client_secret
+
+        self.scopes = scopes if scopes is not None else os.environ.get("SLACK_SCOPES", "").split(",")
+        if isinstance(self.scopes, str):
+            self.scopes = self.scopes.split(",")
+        self.user_scopes = user_scopes if user_scopes is not None else os.environ.get("SLACK_USER_SCOPES", "").split(",")
+        if isinstance(self.user_scopes, str):
+            self.user_scopes = self.user_scopes.split(",")
+
+        self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI")
+        # Handler configuration
+        self.install_path = install_path or os.environ.get("SLACK_INSTALL_PATH", "/slack/install")
+        self.install_page_rendering_enabled = install_page_rendering_enabled
+        self.redirect_uri_path = redirect_uri_path or os.environ.get("SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect")
+        self.callback_options = callback_options
+        self.success_url = success_url
+        self.failure_url = failure_url
+        self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize"
+        # Installation Management
+        self.installation_store = installation_store or get_or_create_default_installation_store(client_id)
+        self.user_token_resolution = user_token_resolution or "authed_user"
+        self.installation_store_bot_only = installation_store_bot_only
+        self.token_rotation_expiration_minutes = token_rotation_expiration_minutes
+        self.authorize = AsyncInstallationStoreAuthorize(
+            logger=logger,
+            client_id=self.client_id,
+            client_secret=self.client_secret,
+            token_rotation_expiration_minutes=self.token_rotation_expiration_minutes,
+            installation_store=self.installation_store,
+            bot_only=self.installation_store_bot_only,
+            user_token_resolution=user_token_resolution,
+        )
+        # state parameter related configurations
+        self.state_validation_enabled = state_validation_enabled
+        self.state_store = state_store or FileOAuthStateStore(
+            expiration_seconds=state_expiration_seconds,
+            client_id=client_id,
+        )
+        self.state_cookie_name = state_cookie_name
+        self.state_expiration_seconds = state_expiration_seconds
+
+        self.state_utils = OAuthStateUtils(
+            cookie_name=self.state_cookie_name,
+            expiration_seconds=self.state_expiration_seconds,
+        )
+        self.authorize_url_generator = AuthorizeUrlGenerator(
+            client_id=self.client_id,
+            redirect_uri=self.redirect_uri,
+            scopes=self.scopes,
+            user_scopes=self.user_scopes,
+            authorization_url=self.authorization_url,
+        )
+        self.redirect_uri_page_renderer = RedirectUriPageRenderer(
+            install_path=self.install_path,
+            redirect_uri_path=self.redirect_uri_path,
+            success_url=self.success_url,
+            failure_url=self.failure_url,
+        )
+
+

The settings for Slack App installation (OAuth flow).

+

Args

+
+
client_id
+
Check the value in Settings > Basic Information > App Credentials
+
client_secret
+
Check the value in Settings > Basic Information > App Credentials
+
scopes
+
Check the value in Settings > Manage Distribution
+
user_scopes
+
Check the value in Settings > Manage Distribution
+
redirect_uri
+
Check the value in Features > OAuth & Permissions > Redirect URLs
+
install_path
+
The endpoint to start an OAuth flow (Default: /slack/install)
+
install_page_rendering_enabled
+
Renders a web page for install_path access if True
+
redirect_uri_path
+
The path of Redirect URL (Default: /slack/oauth_redirect)
+
callback_options
+
Give success/failure functions f you want to customize callback functions.
+
success_url
+
Set a complete URL if you want to redirect end-users when an installation completes.
+
failure_url
+
Set a complete URL if you want to redirect end-users when an installation fails.
+
authorization_url
+
Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize
+
installation_store
+
Specify the instance of InstallationStore (Default: FileInstallationStore)
+
installation_store_bot_only
+
Use InstallationStore#find_bot() if True (Default: False)
+
token_rotation_expiration_minutes
+
Minutes before refreshing tokens (Default: 2 hours)
+
user_token_resolution
+
The option to pick up a user token per request (Default: authed_user) +The available values are "authed_user" and "actor". When you want to resolve the user token per request +using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve +a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect +channels. Note that actor IDs can be absent in some scenarios.
+
state_validation_enabled
+
Set False if your OAuth flow omits the state parameter validation (Default: True)
+
state_store
+
Specify the instance of InstallationStore (Default: FileOAuthStateStore)
+
state_cookie_name
+
The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state")
+
state_expiration_seconds
+
The seconds that the state value is alive (Default: 600 seconds)
+
logger
+
The logger that will be used internally
+
+

Class variables

+
+
var authorization_url : str
+
+

The type of the None singleton.

+
+
var authorizeAsyncAuthorize
+
+

The type of the None singleton.

+
+
var authorize_url_generator : slack_sdk.oauth.authorize_url_generator.AuthorizeUrlGenerator
+
+

The type of the None singleton.

+
+
var callback_optionsAsyncCallbackOptions | None
+
+

The type of the None singleton.

+
+
var client_id : str
+
+

The type of the None singleton.

+
+
var client_secret : str
+
+

The type of the None singleton.

+
+
var failure_url : str | None
+
+

The type of the None singleton.

+
+
var install_page_rendering_enabled : bool
+
+

The type of the None singleton.

+
+
var install_path : str
+
+

The type of the None singleton.

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

The type of the None singleton.

+
+
var installation_store_bot_only : bool
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var redirect_uri : str | None
+
+

The type of the None singleton.

+
+
var redirect_uri_page_renderer : slack_sdk.oauth.redirect_uri_page_renderer.RedirectUriPageRenderer
+
+

The type of the None singleton.

+
+
var redirect_uri_path : str
+
+

The type of the None singleton.

+
+
var scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+ +
+

The type of the None singleton.

+
+
var state_expiration_seconds : int
+
+

The type of the None singleton.

+
+
var state_store : slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore
+
+

The type of the None singleton.

+
+
var state_utils : slack_sdk.oauth.state_utils.OAuthStateUtils
+
+

The type of the None singleton.

+
+
var state_validation_enabled : bool
+
+

The type of the None singleton.

+
+
var success_url : str | None
+
+

The type of the None singleton.

+
+
var token_rotation_expiration_minutes : int
+
+

The type of the None singleton.

+
+
var user_scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/callback_options.html b/docs/reference/oauth/callback_options.html new file mode 100644 index 000000000..c6fc81286 --- /dev/null +++ b/docs/reference/oauth/callback_options.html @@ -0,0 +1,305 @@ + + + + + + +slack_bolt.oauth.callback_options API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.callback_options

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class CallbackOptions +(success: Callable[[SuccessArgs], BoltResponse],
failure: Callable[[FailureArgs], BoltResponse])
+
+
+
+ +Expand source code + +
class CallbackOptions:
+    success: Callable[[SuccessArgs], BoltResponse]
+    failure: Callable[[FailureArgs], BoltResponse]
+
+    def __init__(
+        self,
+        success: Callable[[SuccessArgs], BoltResponse],
+        failure: Callable[[FailureArgs], BoltResponse],
+    ):
+        """The configurations for OAuth flow.
+
+        Args:
+            success: A handler for successful installation.
+            failure: A handler for any types of installation failures.
+        """
+        self.success = success
+        self.failure = failure
+
+

The configurations for OAuth flow.

+

Args

+
+
success
+
A handler for successful installation.
+
failure
+
A handler for any types of installation failures.
+
+

Subclasses

+ +

Class variables

+
+
var failure : Callable[[FailureArgs], BoltResponse]
+
+

The type of the None singleton.

+
+
var success : Callable[[SuccessArgs], BoltResponse]
+
+

The type of the None singleton.

+
+
+
+
+class DefaultCallbackOptions +(*,
logger: logging.Logger,
state_utils: slack_sdk.oauth.state_utils.OAuthStateUtils,
redirect_uri_page_renderer: slack_sdk.oauth.redirect_uri_page_renderer.RedirectUriPageRenderer)
+
+
+
+ +Expand source code + +
class DefaultCallbackOptions(CallbackOptions):
+    success: Callable[[SuccessArgs], BoltResponse]
+    failure: Callable[[FailureArgs], BoltResponse]
+
+    def __init__(
+        self,
+        *,
+        logger: Logger,
+        state_utils: OAuthStateUtils,
+        redirect_uri_page_renderer: RedirectUriPageRenderer,
+    ):
+        self._response_builder = CallbackResponseBuilder(
+            logger=logger or logging.getLogger(__name__),
+            state_utils=state_utils,
+            redirect_uri_page_renderer=redirect_uri_page_renderer,
+        )
+        self.success = self._success_handler
+        self.failure = self._failure_handler
+
+    # --------------------------
+    # Internal methods
+    # --------------------------
+
+    def _success_handler(self, args: SuccessArgs) -> BoltResponse:
+        return self._response_builder._build_callback_success_response(
+            request=args.request,
+            installation=args.installation,
+        )
+
+    def _failure_handler(self, args: FailureArgs) -> BoltResponse:
+        return self._response_builder._build_callback_failure_response(
+            request=args.request,
+            reason=args.reason,
+            status=args.suggested_status_code,
+        )
+
+

The configurations for OAuth flow.

+

Args

+
+
success
+
A handler for successful installation.
+
failure
+
A handler for any types of installation failures.
+
+

Ancestors

+ +

Inherited members

+ +
+
+class FailureArgs +(*,
request: BoltRequest,
reason: str,
error: Exception | None = None,
suggested_status_code: int,
settings: OAuthSettings,
default: CallbackOptions)
+
+
+
+ +Expand source code + +
class FailureArgs:
+    def __init__(
+        self,
+        *,
+        request: BoltRequest,
+        reason: str,
+        error: Optional[Exception] = None,
+        suggested_status_code: int,
+        settings: "OAuthSettings",
+        default: "CallbackOptions",
+    ):
+        """The arguments for a failure function.
+
+        Args:
+            request: The request.
+            reason: The response.
+            error: An exception if exists.
+            suggested_status_code: The recommended HTTP status code for the failure.
+            settings: The settings for Slack OAuth flow.
+            default: The default `CallbackOptions`.
+        """
+        self.request = request
+        self.reason = reason
+        self.error = error
+        self.suggested_status_code = suggested_status_code
+        self.settings = settings
+        self.default = default
+
+

The arguments for a failure function.

+

Args

+
+
request
+
The request.
+
reason
+
The response.
+
error
+
An exception if exists.
+
suggested_status_code
+
The recommended HTTP status code for the failure.
+
settings
+
The settings for Slack OAuth flow.
+
default
+
The default CallbackOptions.
+
+
+
+class SuccessArgs +(*,
request: BoltRequest,
installation: slack_sdk.oauth.installation_store.models.installation.Installation,
settings: OAuthSettings,
default: CallbackOptions)
+
+
+
+ +Expand source code + +
class SuccessArgs:
+    def __init__(
+        self,
+        *,
+        request: BoltRequest,
+        installation: Installation,
+        settings: "OAuthSettings",
+        default: "CallbackOptions",
+    ):
+        """The arguments for a success function.
+
+        Args:
+            request: The request.
+            installation: The installation data.
+            settings: The settings for Slack OAuth flow.
+            default: The default `CallbackOptions`
+        """
+        self.request = request
+        self.installation = installation
+        self.settings = settings
+        self.default = default
+
+

The arguments for a success function.

+

Args

+
+
request
+
The request.
+
installation
+
The installation data.
+
settings
+
The settings for Slack OAuth flow.
+
default
+
The default CallbackOptions
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/index.html b/docs/reference/oauth/index.html new file mode 100644 index 000000000..d53dc6a41 --- /dev/null +++ b/docs/reference/oauth/index.html @@ -0,0 +1,862 @@ + + + + + + +slack_bolt.oauth API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth

+
+
+

Slack OAuth flow support for building an app that is installable in any workspaces.

+

Refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for details.

+
+
+

Sub-modules

+
+
slack_bolt.oauth.async_callback_options
+
+
+
+
slack_bolt.oauth.async_internals
+
+
+
+
slack_bolt.oauth.async_oauth_flow
+
+
+
+
slack_bolt.oauth.async_oauth_settings
+
+
+
+
slack_bolt.oauth.callback_options
+
+
+
+
slack_bolt.oauth.internals
+
+
+
+
slack_bolt.oauth.oauth_flow
+
+
+
+
slack_bolt.oauth.oauth_settings
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class OAuthFlow +(*,
client: slack_sdk.web.client.WebClient | None = None,
logger: logging.Logger | None = None,
settings: OAuthSettings)
+
+
+
+ +Expand source code + +
class OAuthFlow:
+    settings: OAuthSettings
+    client_id: str
+    redirect_uri: Optional[str]
+    install_path: str
+    redirect_uri_path: str
+
+    success_handler: Callable[[SuccessArgs], BoltResponse]
+    failure_handler: Callable[[FailureArgs], BoltResponse]
+
+    def __init__(
+        self,
+        *,
+        client: Optional[WebClient] = None,
+        logger: Optional[Logger] = None,
+        settings: OAuthSettings,
+    ):
+        """The module to run the Slack app installation flow (OAuth flow).
+
+        Args:
+            client: The `slack_sdk.web.WebClient` instance.
+            logger: The logger.
+            settings: OAuth settings to configure this module.
+        """
+        self._client = client
+        self._logger = logger
+        self.settings = settings
+        if self._logger is not None:
+            self.settings.logger = self._logger
+
+        self.client_id = self.settings.client_id
+        self.redirect_uri = self.settings.redirect_uri
+        self.install_path = self.settings.install_path
+        self.redirect_uri_path = self.settings.redirect_uri_path
+
+        self.default_callback_options = DefaultCallbackOptions(
+            logger=logger,  # type: ignore[arg-type]
+            state_utils=self.settings.state_utils,
+            redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer,
+        )
+        if settings.callback_options is None:
+            settings.callback_options = self.default_callback_options
+        self.success_handler = settings.callback_options.success
+        self.failure_handler = settings.callback_options.failure
+
+    @property
+    def client(self) -> WebClient:
+        if self._client is None:
+            self._client = create_web_client(logger=self.logger)
+        return self._client
+
+    @property
+    def logger(self) -> Logger:
+        if self._logger is None:
+            self._logger = logging.getLogger(__name__)
+        return self._logger
+
+    # -----------------------------
+    # Factory Methods
+    # -----------------------------
+
+    @classmethod
+    def sqlite3(
+        cls,
+        database: str,
+        # OAuth flow parameters/credentials
+        client_id: Optional[str] = None,  # required
+        client_secret: Optional[str] = None,  # required
+        scopes: Optional[Sequence[str]] = None,
+        user_scopes: Optional[Sequence[str]] = None,
+        redirect_uri: Optional[str] = None,
+        # Handler configuration
+        install_path: Optional[str] = None,
+        redirect_uri_path: Optional[str] = None,
+        callback_options: Optional[CallbackOptions] = None,
+        success_url: Optional[str] = None,
+        failure_url: Optional[str] = None,
+        authorization_url: Optional[str] = None,
+        # Installation Management
+        # state parameter related configurations
+        state_cookie_name: str = OAuthStateUtils.default_cookie_name,
+        state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds,
+        installation_store_bot_only: bool = False,
+        token_rotation_expiration_minutes: int = 120,
+        client: Optional[WebClient] = None,
+        logger: Optional[Logger] = None,
+    ) -> "OAuthFlow":
+
+        client_id = client_id or os.environ["SLACK_CLIENT_ID"]  # required
+        client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"]  # required
+        scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",")
+        user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",")
+        redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI")
+        installation_store = (
+            SQLite3InstallationStore(database=database, client_id=client_id)
+            if logger is None
+            else SQLite3InstallationStore(database=database, client_id=client_id, logger=logger)
+        )
+        state_store = (
+            SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds)
+            if logger is None
+            else SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds, logger=logger)
+        )
+        return OAuthFlow(
+            client=client or WebClient(),
+            logger=logger,
+            settings=OAuthSettings(
+                # OAuth flow parameters/credentials
+                client_id=client_id,
+                client_secret=client_secret,
+                scopes=scopes,
+                user_scopes=user_scopes,
+                redirect_uri=redirect_uri,
+                # Handler configuration
+                install_path=install_path,  # type: ignore[arg-type]
+                redirect_uri_path=redirect_uri_path,  # type: ignore[arg-type]
+                callback_options=callback_options,
+                success_url=success_url,
+                failure_url=failure_url,
+                authorization_url=authorization_url,
+                # Installation Management
+                installation_store=installation_store,
+                installation_store_bot_only=installation_store_bot_only,
+                token_rotation_expiration_minutes=token_rotation_expiration_minutes,
+                # state parameter related configurations
+                state_store=state_store,
+                state_cookie_name=state_cookie_name,
+                state_expiration_seconds=state_expiration_seconds,
+            ),
+        )
+
+    # -----------------------------
+    # Installation
+    # -----------------------------
+
+    def handle_installation(self, request: BoltRequest) -> BoltResponse:
+        set_cookie_value: Optional[str] = None
+        url = self.build_authorize_url("", request)
+        if self.settings.state_validation_enabled is True:
+            state = self.issue_new_state(request)
+            url = self.build_authorize_url(state, request)
+            set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state)
+
+        if self.settings.install_page_rendering_enabled:
+            html = self.build_install_page_html(url, request)
+            return BoltResponse(
+                status=200,
+                body=html,
+                headers=self.append_set_cookie_headers(
+                    {"Content-Type": "text/html; charset=utf-8"},
+                    set_cookie_value,
+                ),
+            )
+        else:
+            return BoltResponse(
+                status=302,
+                body="",
+                headers=self.append_set_cookie_headers(
+                    {"Content-Type": "text/html; charset=utf-8", "Location": url},
+                    set_cookie_value,
+                ),
+            )
+
+    # ----------------------
+    # Internal methods for Installation
+
+    def issue_new_state(self, request: BoltRequest) -> str:
+        return self.settings.state_store.issue()
+
+    def build_authorize_url(self, state: str, request: BoltRequest) -> str:
+        team_ids: Optional[Sequence[str]] = request.query.get("team")
+        return self.settings.authorize_url_generator.generate(
+            state=state,
+            team=team_ids[0] if team_ids is not None else None,
+        )
+
+    def build_install_page_html(self, url: str, request: BoltRequest) -> str:
+        return _build_default_install_page_html(url)
+
+    def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]):
+        if set_cookie_value is not None:
+            headers["Set-Cookie"] = [set_cookie_value]
+        return headers
+
+    # -----------------------------
+    # Callback
+    # -----------------------------
+
+    def handle_callback(self, request: BoltRequest) -> BoltResponse:
+
+        # failure due to end-user's cancellation or invalid redirection to slack.com
+        error = request.query.get("error", [None])[0]
+        if error is not None:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason=error,
+                    suggested_status_code=200,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # state parameter verification
+        if self.settings.state_validation_enabled is True:
+            state = request.query.get("state", [None])[0]
+            if not self.settings.state_utils.is_valid_browser(state, request.headers):
+                return self.failure_handler(
+                    FailureArgs(
+                        request=request,
+                        reason="invalid_browser",
+                        suggested_status_code=400,
+                        settings=self.settings,
+                        default=self.default_callback_options,
+                    )
+                )
+
+            valid_state_consumed = self.settings.state_store.consume(state)  # type: ignore[arg-type]
+            if not valid_state_consumed:
+                return self.failure_handler(
+                    FailureArgs(
+                        request=request,
+                        reason="invalid_state",
+                        suggested_status_code=401,
+                        settings=self.settings,
+                        default=self.default_callback_options,
+                    )
+                )
+
+        # run installation
+        code = request.query.get("code", [None])[0]
+        if code is None:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="missing_code",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        installation = self.run_installation(code)
+        if installation is None:
+            # failed to run installation with the code
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="invalid_code",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # persist the installation
+        try:
+            self.store_installation(request, installation)
+        except BoltError as err:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="storage_error",
+                    error=err,
+                    suggested_status_code=500,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # display a successful completion page to the end-user
+        return self.success_handler(
+            SuccessArgs(
+                request=request,
+                installation=installation,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # ----------------------
+    # Internal methods for Callback
+
+    def run_installation(self, code: str) -> Optional[Installation]:
+        try:
+            oauth_response: SlackResponse = self.client.oauth_v2_access(
+                code=code,
+                client_id=self.settings.client_id,
+                client_secret=self.settings.client_secret,
+                redirect_uri=self.settings.redirect_uri,  # can be None
+            )
+            installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {}
+            is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False
+            installed_team: Dict[str, str] = oauth_response.get("team") or {}
+            installer: Dict[str, str] = oauth_response.get("authed_user") or {}
+            incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {}
+
+            bot_token: Optional[str] = oauth_response.get("access_token")
+            # NOTE: oauth.v2.access doesn't include bot_id in response
+            bot_id: Optional[str] = None
+            enterprise_url: Optional[str] = None
+            if bot_token is not None:
+                auth_test = self.client.auth_test(token=bot_token)
+                bot_id = auth_test["bot_id"]
+                if is_enterprise_install is True:
+                    enterprise_url = auth_test.get("url")
+
+            return Installation(
+                app_id=oauth_response.get("app_id"),
+                enterprise_id=installed_enterprise.get("id"),
+                enterprise_name=installed_enterprise.get("name"),
+                enterprise_url=enterprise_url,
+                team_id=installed_team.get("id"),
+                team_name=installed_team.get("name"),
+                bot_token=bot_token,
+                bot_id=bot_id,
+                bot_user_id=oauth_response.get("bot_user_id"),
+                bot_scopes=oauth_response.get("scope"),  # type: ignore[arg-type] # comma-separated string
+                bot_refresh_token=oauth_response.get("refresh_token"),  # since v1.7
+                bot_token_expires_in=oauth_response.get("expires_in"),  # since v1.7
+                user_id=installer.get("id"),  # type: ignore[arg-type]
+                user_token=installer.get("access_token"),
+                user_scopes=installer.get("scope"),  # type: ignore[arg-type] # comma-separated string
+                user_refresh_token=installer.get("refresh_token"),  # since v1.7
+                user_token_expires_in=installer.get("expires_in"),  # type: ignore[arg-type] # since v1.7
+                incoming_webhook_url=incoming_webhook.get("url"),
+                incoming_webhook_channel=incoming_webhook.get("channel"),
+                incoming_webhook_channel_id=incoming_webhook.get("channel_id"),
+                incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"),
+                is_enterprise_install=is_enterprise_install,
+                token_type=oauth_response.get("token_type"),
+            )
+
+        except SlackApiError as e:
+            message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}"
+            self.logger.warning(message)
+            return None
+
+    def store_installation(self, request: BoltRequest, installation: Installation):
+        # may raise BoltError
+        self.settings.installation_store.save(installation)
+
+

The module to run the Slack app installation flow (OAuth flow).

+

Args

+
+
client
+
The slack_sdk.web.WebClient instance.
+
logger
+
The logger.
+
settings
+
OAuth settings to configure this module.
+
+

Subclasses

+ +

Class variables

+
+
var client_id : str
+
+

The type of the None singleton.

+
+
var failure_handler : Callable[[FailureArgs], BoltResponse]
+
+

The type of the None singleton.

+
+
var install_path : str
+
+

The type of the None singleton.

+
+
var redirect_uri : str | None
+
+

The type of the None singleton.

+
+
var redirect_uri_path : str
+
+

The type of the None singleton.

+
+
var settingsOAuthSettings
+
+

The type of the None singleton.

+
+
var success_handler : Callable[[SuccessArgs], BoltResponse]
+
+

The type of the None singleton.

+
+
+

Static methods

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

Instance variables

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    if self._client is None:
+        self._client = create_web_client(logger=self.logger)
+    return self._client
+
+
+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> Logger:
+    if self._logger is None:
+        self._logger = logging.getLogger(__name__)
+    return self._logger
+
+
+
+
+

Methods

+
+ +
+
+ +Expand source code + +
def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]):
+    if set_cookie_value is not None:
+        headers["Set-Cookie"] = [set_cookie_value]
+    return headers
+
+
+
+
+def build_authorize_url(self,
state: str,
request: BoltRequest) ‑> str
+
+
+
+ +Expand source code + +
def build_authorize_url(self, state: str, request: BoltRequest) -> str:
+    team_ids: Optional[Sequence[str]] = request.query.get("team")
+    return self.settings.authorize_url_generator.generate(
+        state=state,
+        team=team_ids[0] if team_ids is not None else None,
+    )
+
+
+
+
+def build_install_page_html(self,
url: str,
request: BoltRequest) ‑> str
+
+
+
+ +Expand source code + +
def build_install_page_html(self, url: str, request: BoltRequest) -> str:
+    return _build_default_install_page_html(url)
+
+
+
+
+def handle_callback(self,
request: BoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_callback(self, request: BoltRequest) -> BoltResponse:
+
+    # failure due to end-user's cancellation or invalid redirection to slack.com
+    error = request.query.get("error", [None])[0]
+    if error is not None:
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason=error,
+                suggested_status_code=200,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # state parameter verification
+    if self.settings.state_validation_enabled is True:
+        state = request.query.get("state", [None])[0]
+        if not self.settings.state_utils.is_valid_browser(state, request.headers):
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="invalid_browser",
+                    suggested_status_code=400,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        valid_state_consumed = self.settings.state_store.consume(state)  # type: ignore[arg-type]
+        if not valid_state_consumed:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="invalid_state",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+    # run installation
+    code = request.query.get("code", [None])[0]
+    if code is None:
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason="missing_code",
+                suggested_status_code=401,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    installation = self.run_installation(code)
+    if installation is None:
+        # failed to run installation with the code
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason="invalid_code",
+                suggested_status_code=401,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # persist the installation
+    try:
+        self.store_installation(request, installation)
+    except BoltError as err:
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason="storage_error",
+                error=err,
+                suggested_status_code=500,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # display a successful completion page to the end-user
+    return self.success_handler(
+        SuccessArgs(
+            request=request,
+            installation=installation,
+            settings=self.settings,
+            default=self.default_callback_options,
+        )
+    )
+
+
+
+
+def handle_installation(self,
request: BoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_installation(self, request: BoltRequest) -> BoltResponse:
+    set_cookie_value: Optional[str] = None
+    url = self.build_authorize_url("", request)
+    if self.settings.state_validation_enabled is True:
+        state = self.issue_new_state(request)
+        url = self.build_authorize_url(state, request)
+        set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state)
+
+    if self.settings.install_page_rendering_enabled:
+        html = self.build_install_page_html(url, request)
+        return BoltResponse(
+            status=200,
+            body=html,
+            headers=self.append_set_cookie_headers(
+                {"Content-Type": "text/html; charset=utf-8"},
+                set_cookie_value,
+            ),
+        )
+    else:
+        return BoltResponse(
+            status=302,
+            body="",
+            headers=self.append_set_cookie_headers(
+                {"Content-Type": "text/html; charset=utf-8", "Location": url},
+                set_cookie_value,
+            ),
+        )
+
+
+
+
+def issue_new_state(self,
request: BoltRequest) ‑> str
+
+
+
+ +Expand source code + +
def issue_new_state(self, request: BoltRequest) -> str:
+    return self.settings.state_store.issue()
+
+
+
+
+def run_installation(self, code: str) ‑> slack_sdk.oauth.installation_store.models.installation.Installation | None +
+
+
+ +Expand source code + +
def run_installation(self, code: str) -> Optional[Installation]:
+    try:
+        oauth_response: SlackResponse = self.client.oauth_v2_access(
+            code=code,
+            client_id=self.settings.client_id,
+            client_secret=self.settings.client_secret,
+            redirect_uri=self.settings.redirect_uri,  # can be None
+        )
+        installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {}
+        is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False
+        installed_team: Dict[str, str] = oauth_response.get("team") or {}
+        installer: Dict[str, str] = oauth_response.get("authed_user") or {}
+        incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {}
+
+        bot_token: Optional[str] = oauth_response.get("access_token")
+        # NOTE: oauth.v2.access doesn't include bot_id in response
+        bot_id: Optional[str] = None
+        enterprise_url: Optional[str] = None
+        if bot_token is not None:
+            auth_test = self.client.auth_test(token=bot_token)
+            bot_id = auth_test["bot_id"]
+            if is_enterprise_install is True:
+                enterprise_url = auth_test.get("url")
+
+        return Installation(
+            app_id=oauth_response.get("app_id"),
+            enterprise_id=installed_enterprise.get("id"),
+            enterprise_name=installed_enterprise.get("name"),
+            enterprise_url=enterprise_url,
+            team_id=installed_team.get("id"),
+            team_name=installed_team.get("name"),
+            bot_token=bot_token,
+            bot_id=bot_id,
+            bot_user_id=oauth_response.get("bot_user_id"),
+            bot_scopes=oauth_response.get("scope"),  # type: ignore[arg-type] # comma-separated string
+            bot_refresh_token=oauth_response.get("refresh_token"),  # since v1.7
+            bot_token_expires_in=oauth_response.get("expires_in"),  # since v1.7
+            user_id=installer.get("id"),  # type: ignore[arg-type]
+            user_token=installer.get("access_token"),
+            user_scopes=installer.get("scope"),  # type: ignore[arg-type] # comma-separated string
+            user_refresh_token=installer.get("refresh_token"),  # since v1.7
+            user_token_expires_in=installer.get("expires_in"),  # type: ignore[arg-type] # since v1.7
+            incoming_webhook_url=incoming_webhook.get("url"),
+            incoming_webhook_channel=incoming_webhook.get("channel"),
+            incoming_webhook_channel_id=incoming_webhook.get("channel_id"),
+            incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"),
+            is_enterprise_install=is_enterprise_install,
+            token_type=oauth_response.get("token_type"),
+        )
+
+    except SlackApiError as e:
+        message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}"
+        self.logger.warning(message)
+        return None
+
+
+
+
+def store_installation(self,
request: BoltRequest,
installation: slack_sdk.oauth.installation_store.models.installation.Installation)
+
+
+
+ +Expand source code + +
def store_installation(self, request: BoltRequest, installation: Installation):
+    # may raise BoltError
+    self.settings.installation_store.save(installation)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/internals.html b/docs/reference/oauth/internals.html new file mode 100644 index 000000000..3f1b43a7e --- /dev/null +++ b/docs/reference/oauth/internals.html @@ -0,0 +1,231 @@ + + + + + + +slack_bolt.oauth.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.internals

+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_detailed_error(reason: str) ‑> str +
+
+
+ +Expand source code + +
def build_detailed_error(reason: str) -> str:
+    if reason == "invalid_browser":
+        return (
+            f"{reason}: This can occur due to page reload, "
+            "not beginning the OAuth flow from the valid starting URL, or "
+            "the /slack/install URL not using https://"
+        )
+    elif reason == "invalid_state":
+        return f"{reason}: The state parameter is no longer valid."
+    elif reason == "missing_code":
+        return f"{reason}: The code parameter is missing in this redirection."
+    elif reason == "storage_error":
+        return f"{reason}: The app's server encountered an issue. Contact the app developer."
+    else:
+        return f"{html.escape(reason)}: This error code is returned from Slack. Refer to the documents for details."
+
+
+
+
+def get_or_create_default_installation_store(client_id: str) ‑> slack_sdk.oauth.installation_store.installation_store.InstallationStore +
+
+
+ +Expand source code + +
def get_or_create_default_installation_store(client_id: str) -> InstallationStore:
+    store = default_installation_stores.get(client_id)
+    if store is None:
+        store = FileInstallationStore(client_id=client_id)
+        default_installation_stores[client_id] = store
+    return store
+
+
+
+
+def select_consistent_installation_store(client_id: str,
app_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None,
oauth_flow_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None,
logger: logging.Logger) ‑> slack_sdk.oauth.installation_store.installation_store.InstallationStore | None
+
+
+
+ +Expand source code + +
def select_consistent_installation_store(
+    client_id: str,
+    app_store: Optional[InstallationStore],
+    oauth_flow_store: Optional[InstallationStore],
+    logger: Logger,
+) -> Optional[InstallationStore]:
+    default = get_or_create_default_installation_store(client_id)
+    if app_store is not None:
+        if oauth_flow_store is not None:
+            if oauth_flow_store is default:
+                # only app_store is intentionally set in this case
+                return app_store
+
+            # if both are intentionally set, prioritize app_store
+            if oauth_flow_store is not app_store:
+                logger.warning(warning_installation_store_conflicts())
+            return oauth_flow_store
+        else:
+            # only app_store is available
+            return app_store
+    else:
+        # only oauth_flow_store is available
+        return oauth_flow_store
+
+
+
+
+
+
+

Classes

+
+
+class CallbackResponseBuilder +(*,
logger: logging.Logger,
state_utils: slack_sdk.oauth.state_utils.OAuthStateUtils,
redirect_uri_page_renderer: slack_sdk.oauth.redirect_uri_page_renderer.RedirectUriPageRenderer)
+
+
+
+ +Expand source code + +
class CallbackResponseBuilder:
+    def __init__(
+        self,
+        *,
+        logger: Logger,
+        state_utils: OAuthStateUtils,
+        redirect_uri_page_renderer: RedirectUriPageRenderer,
+    ):
+        self._logger = logger
+        self._state_utils = state_utils
+        self._redirect_uri_page_renderer = redirect_uri_page_renderer
+
+    def _build_callback_success_response(
+        self,
+        request: Union[BoltRequest, "AsyncBoltRequest"],  # type: ignore[name-defined]
+        installation: Installation,
+    ) -> BoltResponse:
+        debug_message = f"Handling an OAuth callback success (request: {request.query})"
+        self._logger.debug(debug_message)
+
+        page_content = self._redirect_uri_page_renderer.render_success_page(
+            app_id=installation.app_id,  # type: ignore[arg-type]
+            team_id=installation.team_id,
+            is_enterprise_install=installation.is_enterprise_install,
+            enterprise_url=installation.enterprise_url,
+        )
+        return BoltResponse(
+            status=200,
+            headers={
+                "Content-Type": "text/html; charset=utf-8",
+                "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(),
+            },
+            body=page_content,
+        )
+
+    def _build_callback_failure_response(
+        self,
+        request: Union[BoltRequest, "AsyncBoltRequest"],  # type: ignore[name-defined]
+        reason: str,
+        status: int = 500,
+        error: Optional[Exception] = None,
+    ) -> BoltResponse:
+        debug_message = "Handling an OAuth callback failure " f"(reason: {reason}, error: {error}, request: {request.query})"
+        self._logger.debug(debug_message)
+
+        # Adding a bit more details to the error code to help installers understand what's happening.
+        # This modification in the HTML page works only when developers use this built-in failure handler.
+        detailed_error = build_detailed_error(reason)
+        return BoltResponse(
+            status=status,
+            headers={
+                "Content-Type": "text/html; charset=utf-8",
+                "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(),
+            },
+            body=self._redirect_uri_page_renderer.render_failure_page(detailed_error),
+        )
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/oauth_flow.html b/docs/reference/oauth/oauth_flow.html new file mode 100644 index 000000000..75aa3cb88 --- /dev/null +++ b/docs/reference/oauth/oauth_flow.html @@ -0,0 +1,813 @@ + + + + + + +slack_bolt.oauth.oauth_flow API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.oauth_flow

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class OAuthFlow +(*,
client: slack_sdk.web.client.WebClient | None = None,
logger: logging.Logger | None = None,
settings: OAuthSettings)
+
+
+
+ +Expand source code + +
class OAuthFlow:
+    settings: OAuthSettings
+    client_id: str
+    redirect_uri: Optional[str]
+    install_path: str
+    redirect_uri_path: str
+
+    success_handler: Callable[[SuccessArgs], BoltResponse]
+    failure_handler: Callable[[FailureArgs], BoltResponse]
+
+    def __init__(
+        self,
+        *,
+        client: Optional[WebClient] = None,
+        logger: Optional[Logger] = None,
+        settings: OAuthSettings,
+    ):
+        """The module to run the Slack app installation flow (OAuth flow).
+
+        Args:
+            client: The `slack_sdk.web.WebClient` instance.
+            logger: The logger.
+            settings: OAuth settings to configure this module.
+        """
+        self._client = client
+        self._logger = logger
+        self.settings = settings
+        if self._logger is not None:
+            self.settings.logger = self._logger
+
+        self.client_id = self.settings.client_id
+        self.redirect_uri = self.settings.redirect_uri
+        self.install_path = self.settings.install_path
+        self.redirect_uri_path = self.settings.redirect_uri_path
+
+        self.default_callback_options = DefaultCallbackOptions(
+            logger=logger,  # type: ignore[arg-type]
+            state_utils=self.settings.state_utils,
+            redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer,
+        )
+        if settings.callback_options is None:
+            settings.callback_options = self.default_callback_options
+        self.success_handler = settings.callback_options.success
+        self.failure_handler = settings.callback_options.failure
+
+    @property
+    def client(self) -> WebClient:
+        if self._client is None:
+            self._client = create_web_client(logger=self.logger)
+        return self._client
+
+    @property
+    def logger(self) -> Logger:
+        if self._logger is None:
+            self._logger = logging.getLogger(__name__)
+        return self._logger
+
+    # -----------------------------
+    # Factory Methods
+    # -----------------------------
+
+    @classmethod
+    def sqlite3(
+        cls,
+        database: str,
+        # OAuth flow parameters/credentials
+        client_id: Optional[str] = None,  # required
+        client_secret: Optional[str] = None,  # required
+        scopes: Optional[Sequence[str]] = None,
+        user_scopes: Optional[Sequence[str]] = None,
+        redirect_uri: Optional[str] = None,
+        # Handler configuration
+        install_path: Optional[str] = None,
+        redirect_uri_path: Optional[str] = None,
+        callback_options: Optional[CallbackOptions] = None,
+        success_url: Optional[str] = None,
+        failure_url: Optional[str] = None,
+        authorization_url: Optional[str] = None,
+        # Installation Management
+        # state parameter related configurations
+        state_cookie_name: str = OAuthStateUtils.default_cookie_name,
+        state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds,
+        installation_store_bot_only: bool = False,
+        token_rotation_expiration_minutes: int = 120,
+        client: Optional[WebClient] = None,
+        logger: Optional[Logger] = None,
+    ) -> "OAuthFlow":
+
+        client_id = client_id or os.environ["SLACK_CLIENT_ID"]  # required
+        client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"]  # required
+        scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",")
+        user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",")
+        redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI")
+        installation_store = (
+            SQLite3InstallationStore(database=database, client_id=client_id)
+            if logger is None
+            else SQLite3InstallationStore(database=database, client_id=client_id, logger=logger)
+        )
+        state_store = (
+            SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds)
+            if logger is None
+            else SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds, logger=logger)
+        )
+        return OAuthFlow(
+            client=client or WebClient(),
+            logger=logger,
+            settings=OAuthSettings(
+                # OAuth flow parameters/credentials
+                client_id=client_id,
+                client_secret=client_secret,
+                scopes=scopes,
+                user_scopes=user_scopes,
+                redirect_uri=redirect_uri,
+                # Handler configuration
+                install_path=install_path,  # type: ignore[arg-type]
+                redirect_uri_path=redirect_uri_path,  # type: ignore[arg-type]
+                callback_options=callback_options,
+                success_url=success_url,
+                failure_url=failure_url,
+                authorization_url=authorization_url,
+                # Installation Management
+                installation_store=installation_store,
+                installation_store_bot_only=installation_store_bot_only,
+                token_rotation_expiration_minutes=token_rotation_expiration_minutes,
+                # state parameter related configurations
+                state_store=state_store,
+                state_cookie_name=state_cookie_name,
+                state_expiration_seconds=state_expiration_seconds,
+            ),
+        )
+
+    # -----------------------------
+    # Installation
+    # -----------------------------
+
+    def handle_installation(self, request: BoltRequest) -> BoltResponse:
+        set_cookie_value: Optional[str] = None
+        url = self.build_authorize_url("", request)
+        if self.settings.state_validation_enabled is True:
+            state = self.issue_new_state(request)
+            url = self.build_authorize_url(state, request)
+            set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state)
+
+        if self.settings.install_page_rendering_enabled:
+            html = self.build_install_page_html(url, request)
+            return BoltResponse(
+                status=200,
+                body=html,
+                headers=self.append_set_cookie_headers(
+                    {"Content-Type": "text/html; charset=utf-8"},
+                    set_cookie_value,
+                ),
+            )
+        else:
+            return BoltResponse(
+                status=302,
+                body="",
+                headers=self.append_set_cookie_headers(
+                    {"Content-Type": "text/html; charset=utf-8", "Location": url},
+                    set_cookie_value,
+                ),
+            )
+
+    # ----------------------
+    # Internal methods for Installation
+
+    def issue_new_state(self, request: BoltRequest) -> str:
+        return self.settings.state_store.issue()
+
+    def build_authorize_url(self, state: str, request: BoltRequest) -> str:
+        team_ids: Optional[Sequence[str]] = request.query.get("team")
+        return self.settings.authorize_url_generator.generate(
+            state=state,
+            team=team_ids[0] if team_ids is not None else None,
+        )
+
+    def build_install_page_html(self, url: str, request: BoltRequest) -> str:
+        return _build_default_install_page_html(url)
+
+    def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]):
+        if set_cookie_value is not None:
+            headers["Set-Cookie"] = [set_cookie_value]
+        return headers
+
+    # -----------------------------
+    # Callback
+    # -----------------------------
+
+    def handle_callback(self, request: BoltRequest) -> BoltResponse:
+
+        # failure due to end-user's cancellation or invalid redirection to slack.com
+        error = request.query.get("error", [None])[0]
+        if error is not None:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason=error,
+                    suggested_status_code=200,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # state parameter verification
+        if self.settings.state_validation_enabled is True:
+            state = request.query.get("state", [None])[0]
+            if not self.settings.state_utils.is_valid_browser(state, request.headers):
+                return self.failure_handler(
+                    FailureArgs(
+                        request=request,
+                        reason="invalid_browser",
+                        suggested_status_code=400,
+                        settings=self.settings,
+                        default=self.default_callback_options,
+                    )
+                )
+
+            valid_state_consumed = self.settings.state_store.consume(state)  # type: ignore[arg-type]
+            if not valid_state_consumed:
+                return self.failure_handler(
+                    FailureArgs(
+                        request=request,
+                        reason="invalid_state",
+                        suggested_status_code=401,
+                        settings=self.settings,
+                        default=self.default_callback_options,
+                    )
+                )
+
+        # run installation
+        code = request.query.get("code", [None])[0]
+        if code is None:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="missing_code",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        installation = self.run_installation(code)
+        if installation is None:
+            # failed to run installation with the code
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="invalid_code",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # persist the installation
+        try:
+            self.store_installation(request, installation)
+        except BoltError as err:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="storage_error",
+                    error=err,
+                    suggested_status_code=500,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        # display a successful completion page to the end-user
+        return self.success_handler(
+            SuccessArgs(
+                request=request,
+                installation=installation,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # ----------------------
+    # Internal methods for Callback
+
+    def run_installation(self, code: str) -> Optional[Installation]:
+        try:
+            oauth_response: SlackResponse = self.client.oauth_v2_access(
+                code=code,
+                client_id=self.settings.client_id,
+                client_secret=self.settings.client_secret,
+                redirect_uri=self.settings.redirect_uri,  # can be None
+            )
+            installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {}
+            is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False
+            installed_team: Dict[str, str] = oauth_response.get("team") or {}
+            installer: Dict[str, str] = oauth_response.get("authed_user") or {}
+            incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {}
+
+            bot_token: Optional[str] = oauth_response.get("access_token")
+            # NOTE: oauth.v2.access doesn't include bot_id in response
+            bot_id: Optional[str] = None
+            enterprise_url: Optional[str] = None
+            if bot_token is not None:
+                auth_test = self.client.auth_test(token=bot_token)
+                bot_id = auth_test["bot_id"]
+                if is_enterprise_install is True:
+                    enterprise_url = auth_test.get("url")
+
+            return Installation(
+                app_id=oauth_response.get("app_id"),
+                enterprise_id=installed_enterprise.get("id"),
+                enterprise_name=installed_enterprise.get("name"),
+                enterprise_url=enterprise_url,
+                team_id=installed_team.get("id"),
+                team_name=installed_team.get("name"),
+                bot_token=bot_token,
+                bot_id=bot_id,
+                bot_user_id=oauth_response.get("bot_user_id"),
+                bot_scopes=oauth_response.get("scope"),  # type: ignore[arg-type] # comma-separated string
+                bot_refresh_token=oauth_response.get("refresh_token"),  # since v1.7
+                bot_token_expires_in=oauth_response.get("expires_in"),  # since v1.7
+                user_id=installer.get("id"),  # type: ignore[arg-type]
+                user_token=installer.get("access_token"),
+                user_scopes=installer.get("scope"),  # type: ignore[arg-type] # comma-separated string
+                user_refresh_token=installer.get("refresh_token"),  # since v1.7
+                user_token_expires_in=installer.get("expires_in"),  # type: ignore[arg-type] # since v1.7
+                incoming_webhook_url=incoming_webhook.get("url"),
+                incoming_webhook_channel=incoming_webhook.get("channel"),
+                incoming_webhook_channel_id=incoming_webhook.get("channel_id"),
+                incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"),
+                is_enterprise_install=is_enterprise_install,
+                token_type=oauth_response.get("token_type"),
+            )
+
+        except SlackApiError as e:
+            message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}"
+            self.logger.warning(message)
+            return None
+
+    def store_installation(self, request: BoltRequest, installation: Installation):
+        # may raise BoltError
+        self.settings.installation_store.save(installation)
+
+

The module to run the Slack app installation flow (OAuth flow).

+

Args

+
+
client
+
The slack_sdk.web.WebClient instance.
+
logger
+
The logger.
+
settings
+
OAuth settings to configure this module.
+
+

Subclasses

+ +

Class variables

+
+
var client_id : str
+
+

The type of the None singleton.

+
+
var failure_handler : Callable[[FailureArgs], BoltResponse]
+
+

The type of the None singleton.

+
+
var install_path : str
+
+

The type of the None singleton.

+
+
var redirect_uri : str | None
+
+

The type of the None singleton.

+
+
var redirect_uri_path : str
+
+

The type of the None singleton.

+
+
var settingsOAuthSettings
+
+

The type of the None singleton.

+
+
var success_handler : Callable[[SuccessArgs], BoltResponse]
+
+

The type of the None singleton.

+
+
+

Static methods

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

Instance variables

+
+
prop client : slack_sdk.web.client.WebClient
+
+
+ +Expand source code + +
@property
+def client(self) -> WebClient:
+    if self._client is None:
+        self._client = create_web_client(logger=self.logger)
+    return self._client
+
+
+
+
prop logger : logging.Logger
+
+
+ +Expand source code + +
@property
+def logger(self) -> Logger:
+    if self._logger is None:
+        self._logger = logging.getLogger(__name__)
+    return self._logger
+
+
+
+
+

Methods

+
+ +
+
+ +Expand source code + +
def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]):
+    if set_cookie_value is not None:
+        headers["Set-Cookie"] = [set_cookie_value]
+    return headers
+
+
+
+
+def build_authorize_url(self,
state: str,
request: BoltRequest) ‑> str
+
+
+
+ +Expand source code + +
def build_authorize_url(self, state: str, request: BoltRequest) -> str:
+    team_ids: Optional[Sequence[str]] = request.query.get("team")
+    return self.settings.authorize_url_generator.generate(
+        state=state,
+        team=team_ids[0] if team_ids is not None else None,
+    )
+
+
+
+
+def build_install_page_html(self,
url: str,
request: BoltRequest) ‑> str
+
+
+
+ +Expand source code + +
def build_install_page_html(self, url: str, request: BoltRequest) -> str:
+    return _build_default_install_page_html(url)
+
+
+
+
+def handle_callback(self,
request: BoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_callback(self, request: BoltRequest) -> BoltResponse:
+
+    # failure due to end-user's cancellation or invalid redirection to slack.com
+    error = request.query.get("error", [None])[0]
+    if error is not None:
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason=error,
+                suggested_status_code=200,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # state parameter verification
+    if self.settings.state_validation_enabled is True:
+        state = request.query.get("state", [None])[0]
+        if not self.settings.state_utils.is_valid_browser(state, request.headers):
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="invalid_browser",
+                    suggested_status_code=400,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+        valid_state_consumed = self.settings.state_store.consume(state)  # type: ignore[arg-type]
+        if not valid_state_consumed:
+            return self.failure_handler(
+                FailureArgs(
+                    request=request,
+                    reason="invalid_state",
+                    suggested_status_code=401,
+                    settings=self.settings,
+                    default=self.default_callback_options,
+                )
+            )
+
+    # run installation
+    code = request.query.get("code", [None])[0]
+    if code is None:
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason="missing_code",
+                suggested_status_code=401,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    installation = self.run_installation(code)
+    if installation is None:
+        # failed to run installation with the code
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason="invalid_code",
+                suggested_status_code=401,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # persist the installation
+    try:
+        self.store_installation(request, installation)
+    except BoltError as err:
+        return self.failure_handler(
+            FailureArgs(
+                request=request,
+                reason="storage_error",
+                error=err,
+                suggested_status_code=500,
+                settings=self.settings,
+                default=self.default_callback_options,
+            )
+        )
+
+    # display a successful completion page to the end-user
+    return self.success_handler(
+        SuccessArgs(
+            request=request,
+            installation=installation,
+            settings=self.settings,
+            default=self.default_callback_options,
+        )
+    )
+
+
+
+
+def handle_installation(self,
request: BoltRequest) ‑> BoltResponse
+
+
+
+ +Expand source code + +
def handle_installation(self, request: BoltRequest) -> BoltResponse:
+    set_cookie_value: Optional[str] = None
+    url = self.build_authorize_url("", request)
+    if self.settings.state_validation_enabled is True:
+        state = self.issue_new_state(request)
+        url = self.build_authorize_url(state, request)
+        set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state)
+
+    if self.settings.install_page_rendering_enabled:
+        html = self.build_install_page_html(url, request)
+        return BoltResponse(
+            status=200,
+            body=html,
+            headers=self.append_set_cookie_headers(
+                {"Content-Type": "text/html; charset=utf-8"},
+                set_cookie_value,
+            ),
+        )
+    else:
+        return BoltResponse(
+            status=302,
+            body="",
+            headers=self.append_set_cookie_headers(
+                {"Content-Type": "text/html; charset=utf-8", "Location": url},
+                set_cookie_value,
+            ),
+        )
+
+
+
+
+def issue_new_state(self,
request: BoltRequest) ‑> str
+
+
+
+ +Expand source code + +
def issue_new_state(self, request: BoltRequest) -> str:
+    return self.settings.state_store.issue()
+
+
+
+
+def run_installation(self, code: str) ‑> slack_sdk.oauth.installation_store.models.installation.Installation | None +
+
+
+ +Expand source code + +
def run_installation(self, code: str) -> Optional[Installation]:
+    try:
+        oauth_response: SlackResponse = self.client.oauth_v2_access(
+            code=code,
+            client_id=self.settings.client_id,
+            client_secret=self.settings.client_secret,
+            redirect_uri=self.settings.redirect_uri,  # can be None
+        )
+        installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {}
+        is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False
+        installed_team: Dict[str, str] = oauth_response.get("team") or {}
+        installer: Dict[str, str] = oauth_response.get("authed_user") or {}
+        incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {}
+
+        bot_token: Optional[str] = oauth_response.get("access_token")
+        # NOTE: oauth.v2.access doesn't include bot_id in response
+        bot_id: Optional[str] = None
+        enterprise_url: Optional[str] = None
+        if bot_token is not None:
+            auth_test = self.client.auth_test(token=bot_token)
+            bot_id = auth_test["bot_id"]
+            if is_enterprise_install is True:
+                enterprise_url = auth_test.get("url")
+
+        return Installation(
+            app_id=oauth_response.get("app_id"),
+            enterprise_id=installed_enterprise.get("id"),
+            enterprise_name=installed_enterprise.get("name"),
+            enterprise_url=enterprise_url,
+            team_id=installed_team.get("id"),
+            team_name=installed_team.get("name"),
+            bot_token=bot_token,
+            bot_id=bot_id,
+            bot_user_id=oauth_response.get("bot_user_id"),
+            bot_scopes=oauth_response.get("scope"),  # type: ignore[arg-type] # comma-separated string
+            bot_refresh_token=oauth_response.get("refresh_token"),  # since v1.7
+            bot_token_expires_in=oauth_response.get("expires_in"),  # since v1.7
+            user_id=installer.get("id"),  # type: ignore[arg-type]
+            user_token=installer.get("access_token"),
+            user_scopes=installer.get("scope"),  # type: ignore[arg-type] # comma-separated string
+            user_refresh_token=installer.get("refresh_token"),  # since v1.7
+            user_token_expires_in=installer.get("expires_in"),  # type: ignore[arg-type] # since v1.7
+            incoming_webhook_url=incoming_webhook.get("url"),
+            incoming_webhook_channel=incoming_webhook.get("channel"),
+            incoming_webhook_channel_id=incoming_webhook.get("channel_id"),
+            incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"),
+            is_enterprise_install=is_enterprise_install,
+            token_type=oauth_response.get("token_type"),
+        )
+
+    except SlackApiError as e:
+        message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}"
+        self.logger.warning(message)
+        return None
+
+
+
+
+def store_installation(self,
request: BoltRequest,
installation: slack_sdk.oauth.installation_store.models.installation.Installation)
+
+
+
+ +Expand source code + +
def store_installation(self, request: BoltRequest, installation: Installation):
+    # may raise BoltError
+    self.settings.installation_store.save(installation)
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/oauth/oauth_settings.html b/docs/reference/oauth/oauth_settings.html new file mode 100644 index 000000000..cd8def497 --- /dev/null +++ b/docs/reference/oauth/oauth_settings.html @@ -0,0 +1,421 @@ + + + + + + +slack_bolt.oauth.oauth_settings API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.oauth.oauth_settings

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class OAuthSettings +(*,
client_id: str | None = None,
client_secret: str | None = None,
scopes: Sequence[str] | str | None = None,
user_scopes: Sequence[str] | str | None = None,
redirect_uri: str | None = None,
install_path: str = '/slack/install',
install_page_rendering_enabled: bool = True,
redirect_uri_path: str = '/slack/oauth_redirect',
callback_options: CallbackOptions | None = None,
success_url: str | None = None,
failure_url: str | None = None,
authorization_url: str | None = None,
installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
installation_store_bot_only: bool = False,
token_rotation_expiration_minutes: int = 120,
user_token_resolution: str = 'authed_user',
state_validation_enabled: bool = True,
state_store: slack_sdk.oauth.state_store.state_store.OAuthStateStore | None = None,
state_cookie_name: str = 'slack-app-oauth-state',
state_expiration_seconds: int = 600,
logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
+
+
+
+ +Expand source code + +
class OAuthSettings:
+    # OAuth flow parameters/credentials
+    client_id: str
+    client_secret: str
+    scopes: Optional[Sequence[str]]
+    user_scopes: Optional[Sequence[str]]
+    redirect_uri: Optional[str]
+    # Handler configuration
+    install_path: str
+    install_page_rendering_enabled: bool
+    redirect_uri_path: str
+    callback_options: Optional[CallbackOptions] = None
+    success_url: Optional[str]
+    failure_url: Optional[str]
+    authorization_url: str  # default: https://slack.com/oauth/v2/authorize
+    # Installation Management
+    installation_store: InstallationStore
+    installation_store_bot_only: bool
+    token_rotation_expiration_minutes: int
+    authorize: Authorize
+    user_token_resolution: str  # default: "authed_user"
+    # state parameter related configurations
+    state_validation_enabled: bool
+    state_store: OAuthStateStore
+    state_cookie_name: str
+    state_expiration_seconds: int
+    # Customizable utilities
+    state_utils: OAuthStateUtils
+    authorize_url_generator: AuthorizeUrlGenerator
+    redirect_uri_page_renderer: RedirectUriPageRenderer
+    # Others
+    logger: Logger
+
+    def __init__(
+        self,
+        *,
+        # OAuth flow parameters/credentials
+        client_id: Optional[str] = None,  # required
+        client_secret: Optional[str] = None,  # required
+        scopes: Optional[Union[Sequence[str], str]] = None,
+        user_scopes: Optional[Union[Sequence[str], str]] = None,
+        redirect_uri: Optional[str] = None,
+        # Handler configuration
+        install_path: str = "/slack/install",
+        install_page_rendering_enabled: bool = True,
+        redirect_uri_path: str = "/slack/oauth_redirect",
+        callback_options: Optional[CallbackOptions] = None,
+        success_url: Optional[str] = None,
+        failure_url: Optional[str] = None,
+        authorization_url: Optional[str] = None,
+        # Installation Management
+        installation_store: Optional[InstallationStore] = None,
+        installation_store_bot_only: bool = False,
+        token_rotation_expiration_minutes: int = 120,
+        user_token_resolution: str = "authed_user",
+        # state parameter related configurations
+        state_validation_enabled: bool = True,
+        state_store: Optional[OAuthStateStore] = None,
+        state_cookie_name: str = OAuthStateUtils.default_cookie_name,
+        state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds,
+        # Others
+        logger: Logger = logging.getLogger(__name__),
+    ):
+        """The settings for Slack App installation (OAuth flow).
+
+        Args:
+            client_id: Check the value in Settings > Basic Information > App Credentials
+            client_secret: Check the value in Settings > Basic Information > App Credentials
+            scopes: Check the value in Settings > Manage Distribution
+            user_scopes: Check the value in Settings > Manage Distribution
+            redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs
+            install_path: The endpoint to start an OAuth flow (Default: `/slack/install`)
+            install_page_rendering_enabled: Renders a web page for install_path access if True
+            redirect_uri_path: The path of Redirect URL (Default: `/slack/oauth_redirect`)
+            callback_options: Give success/failure functions f you want to customize callback functions.
+            success_url: Set a complete URL if you want to redirect end-users when an installation completes.
+            failure_url: Set a complete URL if you want to redirect end-users when an installation fails.
+            authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize`
+            installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`)
+            installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False)
+            token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours)
+            user_token_resolution: The option to pick up a user token per request (Default: authed_user)
+                The available values are "authed_user" and "actor". When you want to resolve the user token per request
+                using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve
+                a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect
+                channels. Note that actor IDs can be absent in some scenarios.
+            state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True)
+            state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`)
+            state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state")
+            state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds)
+            logger: The logger that will be used internally
+        """
+        client_id = client_id or os.environ.get("SLACK_CLIENT_ID")
+        client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET")
+        if client_id is None or client_secret is None:
+            raise BoltError("Both client_id and client_secret are required")
+        self.client_id = client_id
+        self.client_secret = client_secret
+
+        self.scopes = scopes if scopes is not None else os.environ.get("SLACK_SCOPES", "").split(",")
+        if isinstance(self.scopes, str):
+            self.scopes = self.scopes.split(",")
+        self.user_scopes = user_scopes if user_scopes is not None else os.environ.get("SLACK_USER_SCOPES", "").split(",")
+        if isinstance(self.user_scopes, str):
+            self.user_scopes = self.user_scopes.split(",")
+        self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI")
+        # Handler configuration
+        self.install_path = install_path or os.environ.get("SLACK_INSTALL_PATH", "/slack/install")
+        self.install_page_rendering_enabled = install_page_rendering_enabled
+        self.redirect_uri_path = redirect_uri_path or os.environ.get("SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect")
+        self.callback_options = callback_options
+        self.success_url = success_url
+        self.failure_url = failure_url
+        self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize"
+        # Installation Management
+        self.installation_store = installation_store or get_or_create_default_installation_store(client_id)
+        self.user_token_resolution = user_token_resolution or "authed_user"
+        self.installation_store_bot_only = installation_store_bot_only
+        self.token_rotation_expiration_minutes = token_rotation_expiration_minutes
+        self.authorize = InstallationStoreAuthorize(
+            logger=logger,
+            client_id=self.client_id,
+            client_secret=self.client_secret,
+            token_rotation_expiration_minutes=self.token_rotation_expiration_minutes,
+            installation_store=self.installation_store,
+            bot_only=self.installation_store_bot_only,
+            user_token_resolution=user_token_resolution,
+        )
+        # state parameter related configurations
+        self.state_validation_enabled = state_validation_enabled
+        self.state_store = state_store or FileOAuthStateStore(
+            expiration_seconds=state_expiration_seconds,
+            client_id=client_id,
+        )
+        self.state_cookie_name = state_cookie_name
+        self.state_expiration_seconds = state_expiration_seconds
+
+        self.state_utils = OAuthStateUtils(
+            cookie_name=self.state_cookie_name,
+            expiration_seconds=self.state_expiration_seconds,
+        )
+        self.authorize_url_generator = AuthorizeUrlGenerator(
+            client_id=self.client_id,
+            redirect_uri=self.redirect_uri,
+            scopes=self.scopes,
+            user_scopes=self.user_scopes,
+            authorization_url=self.authorization_url,
+        )
+        self.redirect_uri_page_renderer = RedirectUriPageRenderer(
+            install_path=self.install_path,
+            redirect_uri_path=self.redirect_uri_path,
+            success_url=self.success_url,
+            failure_url=self.failure_url,
+        )
+
+

The settings for Slack App installation (OAuth flow).

+

Args

+
+
client_id
+
Check the value in Settings > Basic Information > App Credentials
+
client_secret
+
Check the value in Settings > Basic Information > App Credentials
+
scopes
+
Check the value in Settings > Manage Distribution
+
user_scopes
+
Check the value in Settings > Manage Distribution
+
redirect_uri
+
Check the value in Features > OAuth & Permissions > Redirect URLs
+
install_path
+
The endpoint to start an OAuth flow (Default: /slack/install)
+
install_page_rendering_enabled
+
Renders a web page for install_path access if True
+
redirect_uri_path
+
The path of Redirect URL (Default: /slack/oauth_redirect)
+
callback_options
+
Give success/failure functions f you want to customize callback functions.
+
success_url
+
Set a complete URL if you want to redirect end-users when an installation completes.
+
failure_url
+
Set a complete URL if you want to redirect end-users when an installation fails.
+
authorization_url
+
Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize
+
installation_store
+
Specify the instance of InstallationStore (Default: FileInstallationStore)
+
installation_store_bot_only
+
Use InstallationStore#find_bot() if True (Default: False)
+
token_rotation_expiration_minutes
+
Minutes before refreshing tokens (Default: 2 hours)
+
user_token_resolution
+
The option to pick up a user token per request (Default: authed_user) +The available values are "authed_user" and "actor". When you want to resolve the user token per request +using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve +a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect +channels. Note that actor IDs can be absent in some scenarios.
+
state_validation_enabled
+
Set False if your OAuth flow omits the state parameter validation (Default: True)
+
state_store
+
Specify the instance of InstallationStore (Default: FileOAuthStateStore)
+
state_cookie_name
+
The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state")
+
state_expiration_seconds
+
The seconds that the state value is alive (Default: 600 seconds)
+
logger
+
The logger that will be used internally
+
+

Class variables

+
+
var authorization_url : str
+
+

The type of the None singleton.

+
+
var authorizeAuthorize
+
+

The type of the None singleton.

+
+
var authorize_url_generator : slack_sdk.oauth.authorize_url_generator.AuthorizeUrlGenerator
+
+

The type of the None singleton.

+
+
var callback_optionsCallbackOptions | None
+
+

The type of the None singleton.

+
+
var client_id : str
+
+

The type of the None singleton.

+
+
var client_secret : str
+
+

The type of the None singleton.

+
+
var failure_url : str | None
+
+

The type of the None singleton.

+
+
var install_page_rendering_enabled : bool
+
+

The type of the None singleton.

+
+
var install_path : str
+
+

The type of the None singleton.

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

The type of the None singleton.

+
+
var installation_store_bot_only : bool
+
+

The type of the None singleton.

+
+
var logger : logging.Logger
+
+

The type of the None singleton.

+
+
var redirect_uri : str | None
+
+

The type of the None singleton.

+
+
var redirect_uri_page_renderer : slack_sdk.oauth.redirect_uri_page_renderer.RedirectUriPageRenderer
+
+

The type of the None singleton.

+
+
var redirect_uri_path : str
+
+

The type of the None singleton.

+
+
var scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+ +
+

The type of the None singleton.

+
+
var state_expiration_seconds : int
+
+

The type of the None singleton.

+
+
var state_store : slack_sdk.oauth.state_store.state_store.OAuthStateStore
+
+

The type of the None singleton.

+
+
var state_utils : slack_sdk.oauth.state_utils.OAuthStateUtils
+
+

The type of the None singleton.

+
+
var state_validation_enabled : bool
+
+

The type of the None singleton.

+
+
var success_url : str | None
+
+

The type of the None singleton.

+
+
var token_rotation_expiration_minutes : int
+
+

The type of the None singleton.

+
+
var user_scopes : Sequence[str] | None
+
+

The type of the None singleton.

+
+
var user_token_resolution : str
+
+

The type of the None singleton.

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/request/async_internals.html b/docs/reference/request/async_internals.html new file mode 100644 index 000000000..35a250c8d --- /dev/null +++ b/docs/reference/request/async_internals.html @@ -0,0 +1,135 @@ + + + + + + +slack_bolt.request.async_internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.request.async_internals

+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_async_context(context: AsyncBoltContext,
body: Dict[str, Any]) ‑> AsyncBoltContext
+
+
+
+ +Expand source code + +
def build_async_context(
+    context: AsyncBoltContext,
+    body: Dict[str, Any],
+) -> AsyncBoltContext:
+    context["is_enterprise_install"] = extract_is_enterprise_install(body)
+    enterprise_id = extract_enterprise_id(body)
+    if enterprise_id:
+        context["enterprise_id"] = enterprise_id
+    team_id = extract_team_id(body)
+    if team_id:
+        context["team_id"] = team_id
+    user_id = extract_user_id(body)
+    if user_id:
+        context["user_id"] = user_id
+    # Actor IDs are useful for Events API on a Slack Connect channel
+    actor_enterprise_id = extract_actor_enterprise_id(body)
+    if actor_enterprise_id:
+        context["actor_enterprise_id"] = actor_enterprise_id
+    actor_team_id = extract_actor_team_id(body)
+    if actor_team_id:
+        context["actor_team_id"] = actor_team_id
+    actor_user_id = extract_actor_user_id(body)
+    if actor_user_id:
+        context["actor_user_id"] = actor_user_id
+    channel_id = extract_channel_id(body)
+    if channel_id:
+        context["channel_id"] = channel_id
+    thread_ts = extract_thread_ts(body)
+    if thread_ts:
+        context["thread_ts"] = thread_ts
+    function_execution_id = extract_function_execution_id(body)
+    if function_execution_id:
+        context["function_execution_id"] = function_execution_id
+        function_bot_access_token = extract_function_bot_access_token(body)
+        if function_bot_access_token is not None:
+            context["function_bot_access_token"] = function_bot_access_token
+        function_inputs = extract_function_inputs(body)
+        if function_inputs is not None:
+            context["inputs"] = function_inputs
+    if "response_url" in body:
+        context["response_url"] = body["response_url"]
+    elif "response_urls" in body:
+        # In the case where response_url_enabled: true in a modal exists
+        response_urls = body["response_urls"]
+        if len(response_urls) >= 1:
+            if len(response_urls) > 1:
+                context.logger.debug(debug_multiple_response_urls_detected())
+            response_url = response_urls[0].get("response_url")
+            context["response_url"] = response_url
+    return context
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/request/async_request.html b/docs/reference/request/async_request.html new file mode 100644 index 000000000..a3658710a --- /dev/null +++ b/docs/reference/request/async_request.html @@ -0,0 +1,244 @@ + + + + + + +slack_bolt.request.async_request API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.request.async_request

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncBoltRequest +(*,
body: str | dict,
query: str | Dict[str, str] | Dict[str, Sequence[str]] | None = None,
headers: Dict[str, str | Sequence[str]] | None = None,
context: Dict[str, Any] | None = None,
mode: str = 'http')
+
+
+
+ +Expand source code + +
class AsyncBoltRequest:
+    raw_body: str
+    body: Dict[str, Any]
+    query: Dict[str, Sequence[str]]
+    headers: Dict[str, Sequence[str]]
+    content_type: Optional[str]
+    context: AsyncBoltContext
+    lazy_only: bool
+    lazy_function_name: Optional[str]
+    mode: str  # either "http" or "socket_mode"
+
+    def __init__(
+        self,
+        *,
+        body: Union[str, dict],
+        query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None,
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+        context: Optional[Dict[str, Any]] = None,
+        mode: str = "http",  # either "http" or "socket_mode"
+    ):
+        """Request to a Bolt app.
+
+        Args:
+            body: The raw request body (only plain text is supported for "http" mode)
+            query: The query string data in any data format.
+            headers: The request headers.
+            context: The context in this request.
+            mode: The mode used for this request. (either "http" or "socket_mode")
+        """
+
+        if mode == "http":
+            # HTTP Mode
+            if body is not None and not isinstance(body, str):
+                raise BoltError(error_message_raw_body_required_in_http_mode())
+            self.raw_body = body if body is not None else ""
+        else:
+            # Socket Mode
+            if body is not None and isinstance(body, str):
+                self.raw_body = body
+            else:
+                # We don't convert the dict value to str
+                # as doing so does not guarantee to keep the original structure/format.
+                self.raw_body = ""
+
+        self.query = parse_query(query)
+        self.headers = build_normalized_headers(headers)
+        self.content_type = extract_content_type(self.headers)
+
+        if isinstance(body, str):
+            self.body = parse_body(self.raw_body, self.content_type)
+        elif isinstance(body, dict):
+            self.body = body
+        else:
+            self.body = {}
+
+        self.context = build_async_context(AsyncBoltContext(context if context else {}), self.body)
+        self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0])
+        self.lazy_function_name = self.headers.get("x-slack-bolt-lazy-function-name", [None])[0]
+        self.mode = mode
+
+    def to_copyable(self) -> "AsyncBoltRequest":
+        body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+        return AsyncBoltRequest(
+            body=body,
+            query=self.query,
+            headers=self.headers,
+            context=self.context.to_copyable(),
+            mode=self.mode,
+        )
+
+

Request to a Bolt app.

+

Args

+
+
body
+
The raw request body (only plain text is supported for "http" mode)
+
query
+
The query string data in any data format.
+
headers
+
The request headers.
+
context
+
The context in this request.
+
mode
+
The mode used for this request. (either "http" or "socket_mode")
+
+

Class variables

+
+
var body : Dict[str, Any]
+
+

The type of the None singleton.

+
+
var content_type : str | None
+
+

The type of the None singleton.

+
+
var contextAsyncBoltContext
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var lazy_function_name : str | None
+
+

The type of the None singleton.

+
+
var lazy_only : bool
+
+

The type of the None singleton.

+
+
var mode : str
+
+

The type of the None singleton.

+
+
var query : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var raw_body : str
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def to_copyable(self) ‑> AsyncBoltRequest +
+
+
+ +Expand source code + +
def to_copyable(self) -> "AsyncBoltRequest":
+    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+    return AsyncBoltRequest(
+        body=body,
+        query=self.query,
+        headers=self.headers,
+        context=self.context.to_copyable(),
+        mode=self.mode,
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/request/index.html b/docs/reference/request/index.html new file mode 100644 index 000000000..84cd15050 --- /dev/null +++ b/docs/reference/request/index.html @@ -0,0 +1,278 @@ + + + + + + +slack_bolt.request API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.request

+
+
+

Incoming request from Slack through either HTTP request or Socket Mode connection.

+

Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections. +This interface encapsulates the difference between the two.

+
+
+

Sub-modules

+
+
slack_bolt.request.async_internals
+
+
+
+
slack_bolt.request.async_request
+
+
+
+
slack_bolt.request.internals
+
+
+
+
slack_bolt.request.payload_utils
+
+
+
+
slack_bolt.request.request
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BoltRequest +(*,
body: str | dict,
query: str | Dict[str, str] | Dict[str, Sequence[str]] | None = None,
headers: Dict[str, str | Sequence[str]] | None = None,
context: Dict[str, Any] | None = None,
mode: str = 'http')
+
+
+
+ +Expand source code + +
class BoltRequest:
+    raw_body: str
+    query: Dict[str, Sequence[str]]
+    headers: Dict[str, Sequence[str]]
+    content_type: Optional[str]
+    body: Dict[str, Any]
+    context: BoltContext
+    lazy_only: bool
+    lazy_function_name: Optional[str]
+    mode: str  # either "http" or "socket_mode"
+
+    def __init__(
+        self,
+        *,
+        body: Union[str, dict],
+        query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None,
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+        context: Optional[Dict[str, Any]] = None,
+        mode: str = "http",  # either "http" or "socket_mode"
+    ):
+        """Request to a Bolt app.
+
+        Args:
+            body: The raw request body (only plain text is supported for "http" mode)
+            query: The query string data in any data format.
+            headers: The request headers.
+            context: The context in this request.
+            mode: The mode used for this request. (either "http" or "socket_mode")
+        """
+        if mode == "http":
+            # HTTP Mode
+            if body is not None and not isinstance(body, str):
+                raise BoltError(error_message_raw_body_required_in_http_mode())
+            self.raw_body = body if body is not None else ""
+        else:
+            # Socket Mode
+            if body is not None and isinstance(body, str):
+                self.raw_body = body
+            else:
+                # We don't convert the dict value to str
+                # as doing so does not guarantee to keep the original structure/format.
+                self.raw_body = ""
+
+        self.query = parse_query(query)
+        self.headers = build_normalized_headers(headers)
+        self.content_type = extract_content_type(self.headers)
+
+        if isinstance(body, str):
+            self.body = parse_body(self.raw_body, self.content_type)
+        elif isinstance(body, dict):
+            self.body = body
+        else:
+            self.body = {}
+
+        self.context = build_context(BoltContext(context if context else {}), self.body)
+        self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0])
+        self.lazy_function_name = self.headers.get("x-slack-bolt-lazy-function-name", [None])[0]
+        self.mode = mode
+
+    def to_copyable(self) -> "BoltRequest":
+        body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+        return BoltRequest(
+            body=body,
+            query=self.query,
+            headers=self.headers,
+            context=self.context.to_copyable(),
+            mode=self.mode,
+        )
+
+

Request to a Bolt app.

+

Args

+
+
body
+
The raw request body (only plain text is supported for "http" mode)
+
query
+
The query string data in any data format.
+
headers
+
The request headers.
+
context
+
The context in this request.
+
mode
+
The mode used for this request. (either "http" or "socket_mode")
+
+

Class variables

+
+
var body : Dict[str, Any]
+
+

The type of the None singleton.

+
+
var content_type : str | None
+
+

The type of the None singleton.

+
+
var contextBoltContext
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var lazy_function_name : str | None
+
+

The type of the None singleton.

+
+
var lazy_only : bool
+
+

The type of the None singleton.

+
+
var mode : str
+
+

The type of the None singleton.

+
+
var query : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var raw_body : str
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def to_copyable(self) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_copyable(self) -> "BoltRequest":
+    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+    return BoltRequest(
+        body=body,
+        query=self.query,
+        headers=self.headers,
+        context=self.context.to_copyable(),
+        mode=self.mode,
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/request/internals.html b/docs/reference/request/internals.html new file mode 100644 index 000000000..d25880135 --- /dev/null +++ b/docs/reference/request/internals.html @@ -0,0 +1,594 @@ + + + + + + +slack_bolt.request.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.request.internals

+
+
+
+
+
+
+
+
+

Functions

+
+
+def build_context(context: BoltContext,
body: Dict[str, Any]) ‑> BoltContext
+
+
+
+ +Expand source code + +
def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext:
+    context["is_enterprise_install"] = extract_is_enterprise_install(body)
+    enterprise_id = extract_enterprise_id(body)
+    if enterprise_id:
+        context["enterprise_id"] = enterprise_id
+    team_id = extract_team_id(body)
+    if team_id:
+        context["team_id"] = team_id
+    user_id = extract_user_id(body)
+    if user_id:
+        context["user_id"] = user_id
+    # Actor IDs are useful for Events API on a Slack Connect channel
+    actor_enterprise_id = extract_actor_enterprise_id(body)
+    if actor_enterprise_id:
+        context["actor_enterprise_id"] = actor_enterprise_id
+    actor_team_id = extract_actor_team_id(body)
+    if actor_team_id:
+        context["actor_team_id"] = actor_team_id
+    actor_user_id = extract_actor_user_id(body)
+    if actor_user_id:
+        context["actor_user_id"] = actor_user_id
+    channel_id = extract_channel_id(body)
+    if channel_id:
+        context["channel_id"] = channel_id
+    thread_ts = extract_thread_ts(body)
+    if thread_ts:
+        context["thread_ts"] = thread_ts
+    function_execution_id = extract_function_execution_id(body)
+    if function_execution_id is not None:
+        context["function_execution_id"] = function_execution_id
+        function_bot_access_token = extract_function_bot_access_token(body)
+        if function_bot_access_token is not None:
+            context["function_bot_access_token"] = function_bot_access_token
+        inputs = extract_function_inputs(body)
+        if inputs is not None:
+            context["inputs"] = inputs
+    if "response_url" in body:
+        context["response_url"] = body["response_url"]
+    elif "response_urls" in body:
+        # In the case where response_url_enabled: true in a modal exists
+        response_urls = body["response_urls"]
+        if len(response_urls) >= 1:
+            if len(response_urls) > 1:
+                context.logger.debug(debug_multiple_response_urls_detected())
+            response_url = response_urls[0].get("response_url")
+            context["response_url"] = response_url
+    return context
+
+
+
+
+def build_normalized_headers(headers: Dict[str, str | Sequence[str]] | None) ‑> Dict[str, Sequence[str]] +
+
+
+ +Expand source code + +
def build_normalized_headers(headers: Optional[Dict[str, Union[str, Sequence[str]]]]) -> Dict[str, Sequence[str]]:
+    normalized_headers: Dict[str, Sequence[str]] = {}
+    if headers is not None:
+        for key, value in headers.items():
+            normalized_name = key.lower()
+            if isinstance(value, list):
+                normalized_headers[normalized_name] = value
+            elif isinstance(value, str):
+                normalized_headers[normalized_name] = [value]
+            else:
+                raise ValueError(f"Unsupported type ({type(value)}) of element in headers ({headers})")
+    return normalized_headers
+
+
+
+
+def debug_multiple_response_urls_detected() ‑> str +
+
+
+ +Expand source code + +
def debug_multiple_response_urls_detected() -> str:
+    return (
+        "`response_urls` in the body has multiple URLs in it. "
+        "If you would like to use non-primary one, "
+        "please manually extract the one from body['response_urls']."
+    )
+
+
+
+
+def error_message_raw_body_required_in_http_mode() ‑> str +
+
+
+ +Expand source code + +
def error_message_raw_body_required_in_http_mode() -> str:
+    return "`body` must be a raw string data when running in the HTTP server mode"
+
+
+
+
+def extract_actor_enterprise_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_actor_enterprise_id(payload: Dict[str, Any]) -> Optional[str]:
+    if payload.get("is_ext_shared_channel") is True:
+        if payload.get("type") == "event_callback":
+            # For safety, we don't set actor IDs for the events like "file_shared",
+            # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct.
+            event_team_id = payload.get("event", {}).get("user_team") or payload.get("event", {}).get("team")
+            if event_team_id is not None and str(event_team_id).startswith("E"):
+                return event_team_id
+            if event_team_id == payload.get("team_id"):
+                return payload.get("enterprise_id")
+            return None
+    return extract_enterprise_id(payload)
+
+
+
+
+def extract_actor_team_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_actor_team_id(payload: Dict[str, Any]) -> Optional[str]:
+    if payload.get("is_ext_shared_channel") is True:
+        if payload.get("type") == "event_callback":
+            event_type = payload.get("event", {}).get("type")
+            if event_type == "app_mention":
+                # The $.event.user_team can be an enterprise_id in app_mention events.
+                # In the scenario, there is no way to retrieve actor_team_id as of March 2023
+                user_team = payload.get("event", {}).get("user_team")
+                if user_team is None:
+                    # working with an app installed in this user's org/workspace side
+                    return payload.get("event", {}).get("team")
+                if str(user_team).startswith("T"):
+                    # interacting from a connected non-grid workspace
+                    return user_team
+                # Interacting from a connected grid workspace; in this case, team_id cannot be resolved as of March 2023
+                return None
+            # For safety, we don't set actor IDs for the events like "file_shared",
+            # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct.
+            event_user_team = payload.get("event", {}).get("user_team")
+            if event_user_team is not None:
+                if str(event_user_team).startswith("T"):
+                    return event_user_team
+                elif str(event_user_team).startswith("E"):
+                    if event_user_team == payload.get("enterprise_id"):
+                        return payload.get("team_id")
+                    elif event_user_team == payload.get("context_enterprise_id"):
+                        return payload.get("context_team_id")
+
+            event_team = payload.get("event", {}).get("team")
+            if event_team is not None:
+                if str(event_team).startswith("T"):
+                    return event_team
+                elif str(event_team).startswith("E"):
+                    if event_team == payload.get("enterprise_id"):
+                        return payload.get("team_id")
+                    elif event_team == payload.get("context_enterprise_id"):
+                        return payload.get("context_team_id")
+            return None
+
+    return extract_team_id(payload)
+
+
+
+
+def extract_actor_user_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_actor_user_id(payload: Dict[str, Any]) -> Optional[str]:
+    if payload.get("is_ext_shared_channel") is True:
+        if payload.get("type") == "event_callback":
+            event = payload.get("event")
+            if event is None:
+                return None
+            if extract_actor_enterprise_id(payload) is None and extract_actor_team_id(payload) is None:
+                # When both enterprise_id and team_id are not identified, we skip returning user_id too for safety
+                return None
+            return event.get("user") or event.get("user_id")
+    return extract_user_id(payload)
+
+
+
+
+def extract_channel_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]:
+    channel = payload.get("channel")
+    if channel is not None:
+        if isinstance(channel, str):
+            return channel
+        elif "id" in channel:
+            return channel.get("id")
+    if "channel_id" in payload:
+        return payload.get("channel_id")
+    if isinstance(payload.get("event"), dict):
+        return extract_channel_id(payload["event"])
+    if isinstance(payload.get("item"), dict):
+        # reaction_added: body["event"]["item"]
+        return extract_channel_id(payload["item"])
+    if isinstance(payload.get("assistant_thread"), dict):
+        # assistant_thread_started
+        return extract_channel_id(payload["assistant_thread"])
+    return None
+
+
+
+
+def extract_content_type(headers: Dict[str, Sequence[str]]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_content_type(headers: Dict[str, Sequence[str]]) -> Optional[str]:
+    content_type: Optional[str] = headers.get("content-type", [None])[0]
+    if content_type:
+        return content_type.split(";")[0]
+    return None
+
+
+
+
+def extract_enterprise_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]:
+    org = payload.get("enterprise")
+    if org is not None:
+        if isinstance(org, str):
+            return org
+        elif "id" in org:
+            return org.get("id")
+    if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0:
+        # To make Events API handling functioning also for shared channels,
+        # we should use .authorizations[0].enterprise_id over .enterprise_id
+        return extract_enterprise_id(payload["authorizations"][0])
+    if "enterprise_id" in payload:
+        return payload.get("enterprise_id")
+    if 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 isinstance(payload.get("event"), dict):
+        return extract_enterprise_id(payload["event"])
+    return None
+
+
+
+
+def extract_function_bot_access_token(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
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 isinstance(payload.get("event"), dict):
+        return payload["event"].get("bot_access_token")
+    return None
+
+
+
+
+def extract_function_execution_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
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 isinstance(payload.get("event"), dict):
+        return extract_function_execution_id(payload["event"])
+    if isinstance(payload.get("function_data"), dict):
+        return payload["function_data"].get("execution_id")
+    return None
+
+
+
+
+def extract_function_inputs(payload: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def extract_function_inputs(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    if isinstance(payload.get("event"), dict):
+        return payload["event"].get("inputs")
+    if isinstance(payload.get("function_data"), dict):
+        return payload["function_data"].get("inputs")
+    return None
+
+
+
+
+def extract_is_enterprise_install(payload: Dict[str, Any]) ‑> bool | None +
+
+
+ +Expand source code + +
def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]:
+    if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0:
+        # To make Events API handling functioning also for shared channels,
+        # we should use .authorizations[0].is_enterprise_install over .is_enterprise_install
+        return extract_is_enterprise_install(payload["authorizations"][0])
+    if "is_enterprise_install" in payload:
+        is_enterprise_install = payload.get("is_enterprise_install")
+        return is_enterprise_install is not None and (is_enterprise_install is True or is_enterprise_install == "true")
+    return False
+
+
+
+
+def extract_team_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_team_id(payload: Dict[str, Any]) -> Optional[str]:
+    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 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
+        team = payload.get("team")
+        if isinstance(team, str):
+            return team
+        elif team and "id" in team:
+            return team.get("id")
+    if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0:
+        # To make Events API handling functioning also for shared channels,
+        # we should use .authorizations[0].team_id over .team_id
+        return extract_team_id(payload["authorizations"][0])
+    if "team_id" in payload:
+        return payload.get("team_id")
+    if isinstance(payload.get("event"), dict):
+        return extract_team_id(payload["event"])
+    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
+
+
+
+
+def extract_thread_ts(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
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 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
+
+
+
+
+def extract_user_id(payload: Dict[str, Any]) ‑> str | None +
+
+
+ +Expand source code + +
def extract_user_id(payload: Dict[str, Any]) -> Optional[str]:
+    user = payload.get("user")
+    if user is not None:
+        if isinstance(user, str):
+            return user
+        elif "id" in user:
+            return user.get("id")
+    if "user_id" in payload:
+        return payload.get("user_id")
+    if isinstance(payload.get("event"), dict):
+        return extract_user_id(payload["event"])
+    if isinstance(payload.get("message"), dict):
+        # message_changed: body["event"]["message"]
+        return extract_user_id(payload["message"])
+    if isinstance(payload.get("previous_message"), dict):
+        # message_deleted: body["event"]["previous_message"]
+        return extract_user_id(payload["previous_message"])
+    return None
+
+
+
+
+def parse_body(body: str, content_type: str | None) ‑> Dict[str, Any] +
+
+
+ +Expand source code + +
def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]:
+    if not body:
+        return {}
+    if (content_type is not None and content_type == "application/json") or body.startswith("{"):
+        return json.loads(body)
+    else:
+        if "payload" in body:  # This is not JSON format yet
+            params = dict(parse_qsl(body, keep_blank_values=True))
+            payload = params.get("payload")
+            if payload is not None:
+                return json.loads(payload)
+            else:
+                return {}
+        else:
+            return dict(parse_qsl(body, keep_blank_values=True))
+
+
+
+
+def parse_query(query: str | Dict[str, str] | Dict[str, Sequence[str]] | None) ‑> Dict[str, Sequence[str]] +
+
+
+ +Expand source code + +
def parse_query(query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]]) -> Dict[str, Sequence[str]]:
+    if query is None:
+        return {}
+    elif isinstance(query, str):
+        return dict(parse_qs(query, keep_blank_values=True))
+    elif isinstance(query, dict) or hasattr(query, "items"):
+        result: Dict[str, Sequence[str]] = {}
+        for name, value in query.items():
+            if isinstance(value, list):
+                result[name] = value
+            elif isinstance(value, str):
+                result[name] = [value]
+            else:
+                raise ValueError(f"Unsupported type ({type(value)}) of element in headers ({query})")
+        return result
+    else:
+        raise ValueError(f"Unsupported type of query detected ({type(query)})")
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/request/payload_utils.html b/docs/reference/request/payload_utils.html new file mode 100644 index 000000000..b583c3a51 --- /dev/null +++ b/docs/reference/request/payload_utils.html @@ -0,0 +1,669 @@ + + + + + + +slack_bolt.request.payload_utils API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.request.payload_utils

+
+
+
+
+
+
+
+
+

Functions

+
+
+def is_action(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_action(body: Dict[str, Any]) -> bool:
+    return (
+        is_attachment_action(body)
+        or is_block_actions(body)
+        or is_dialog_submission(body)
+        or is_dialog_cancellation(body)
+        or is_workflow_step_edit(body)
+    )
+
+
+
+
+def is_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 +
+
+
+ +Expand source code + +
def is_assistant_event(body: Dict[str, Any]) -> bool:
+    return is_event(body) and (
+        is_assistant_thread_started_event(body)
+        or is_assistant_thread_context_changed_event(body)
+        or is_user_message_event_in_assistant_thread(body)
+        or is_bot_message_event_in_assistant_thread(body)
+    )
+
+
+
+
+def is_assistant_thread_context_changed_event(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool:
+    if is_event(body):
+        return body["event"]["type"] == "assistant_thread_context_changed"
+    return False
+
+
+
+
+def is_assistant_thread_started_event(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_assistant_thread_started_event(body: Dict[str, Any]) -> bool:
+    if is_event(body):
+        return body["event"]["type"] == "assistant_thread_started"
+    return False
+
+
+
+
+def is_attachment_action(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_attachment_action(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "interactive_message") and "callback_id" in body
+
+
+
+
+def is_block_actions(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_block_actions(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "block_actions") and "actions" in body
+
+
+
+
+def is_block_suggestion(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_block_suggestion(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "block_suggestion") and "action_id" in body
+
+
+
+
+def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
+    if is_any_im_message_event(body):
+        return (
+            body["event"].get("subtype") is None
+            and body["event"].get("thread_ts") is not None
+            and body["event"].get("bot_id") is not None
+        )
+    return False
+
+
+
+
+def is_dialog_cancellation(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_dialog_cancellation(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "dialog_cancellation") and "callback_id" in body
+
+
+
+
+def is_dialog_submission(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_dialog_submission(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "dialog_submission") and "callback_id" in body
+
+
+
+
+def is_dialog_suggestion(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_dialog_suggestion(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "dialog_suggestion") and "callback_id" in body
+
+
+
+
+def is_event(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_event(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "event_callback") and "event" in body and "type" in body["event"]
+
+
+
+
+def is_function(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_function(body: Dict[str, Any]) -> bool:
+    return is_event(body) and "function_executed" == body["event"]["type"] and "function_execution_id" in body["event"]
+
+
+
+
+def is_global_shortcut(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_global_shortcut(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "shortcut") and "callback_id" in body
+
+
+
+
+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(body: Dict[str, Any]) -> bool:
+    if is_event(body):
+        return body["event"]["type"] == "message"
+    return False
+
+
+
+
+def is_message_shortcut(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_message_shortcut(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "message_action") and "callback_id" in body
+
+
+
+
+def is_options(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_options(body: Dict[str, Any]) -> bool:
+    return is_block_suggestion(body) or is_dialog_suggestion(body)
+
+
+
+
+def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
+    # message_changed, message_deleted etc.
+    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
+
+
+
+
+def is_shortcut(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_shortcut(body: Dict[str, Any]) -> bool:
+    return is_global_shortcut(body) or is_message_shortcut(body)
+
+
+
+
+def is_slash_command(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_slash_command(body: Dict[str, Any]) -> bool:
+    return body is not None and "command" in body
+
+
+
+
+def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
+    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_view(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_view(body: Dict[str, Any]) -> bool:
+    return is_view_submission(body) or is_view_closed(body)
+
+
+
+
+def is_view_closed(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_view_closed(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "view_closed") and "view" in body and "callback_id" in body["view"]
+
+
+
+
+def is_view_submission(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_view_submission(body: Dict[str, Any]) -> bool:
+    return (
+        body is not None and _is_expected_type(body, "view_submission") and "view" in body and "callback_id" in body["view"]
+    )
+
+
+
+
+def is_workflow_step_edit(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_workflow_step_edit(body: Dict[str, Any]) -> bool:
+    return body is not None and _is_expected_type(body, "workflow_step_edit") and "callback_id" in body
+
+
+
+
+def is_workflow_step_execute(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
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_workflow_step_save(body: Dict[str, Any]) ‑> bool +
+
+
+ +Expand source code + +
def is_workflow_step_save(body: Dict[str, Any]) -> bool:
+    return is_view_submission(body) and body["view"]["type"] == "workflow_step"
+
+
+
+
+def to_action(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_action(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    if is_action(body):
+        if is_block_actions(body) or is_attachment_action(body):
+            return body["actions"][0]
+        else:
+            return body
+    return None
+
+
+
+
+def to_command(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_command(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    return body if is_slash_command(body) else None
+
+
+
+
+def to_event(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_event(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    return body["event"] if is_event(body) else None
+
+
+
+
+def to_message(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    if is_message_event(body):
+        return to_event(body)
+    return None
+
+
+
+
+def to_options(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_options(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    if is_options(body):
+        return body
+    return None
+
+
+
+
+def to_shortcut(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_shortcut(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    if is_shortcut(body):
+        return body
+    return None
+
+
+
+
+def to_step(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_step(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    # edit
+    if is_workflow_step_edit(body):
+        return body["workflow_step"]
+    # save
+    if is_workflow_step_save(body):
+        return body["workflow_step"]
+    # execute
+    if is_workflow_step_execute(body):
+        return body["event"]["workflow_step"]
+    return None
+
+
+
+
+def to_view(body: Dict[str, Any]) ‑> Dict[str, Any] | None +
+
+
+ +Expand source code + +
def to_view(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+    if is_view(body):
+        return body["view"]
+    return None
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/request/request.html b/docs/reference/request/request.html new file mode 100644 index 000000000..870b65f08 --- /dev/null +++ b/docs/reference/request/request.html @@ -0,0 +1,243 @@ + + + + + + +slack_bolt.request.request API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.request.request

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BoltRequest +(*,
body: str | dict,
query: str | Dict[str, str] | Dict[str, Sequence[str]] | None = None,
headers: Dict[str, str | Sequence[str]] | None = None,
context: Dict[str, Any] | None = None,
mode: str = 'http')
+
+
+
+ +Expand source code + +
class BoltRequest:
+    raw_body: str
+    query: Dict[str, Sequence[str]]
+    headers: Dict[str, Sequence[str]]
+    content_type: Optional[str]
+    body: Dict[str, Any]
+    context: BoltContext
+    lazy_only: bool
+    lazy_function_name: Optional[str]
+    mode: str  # either "http" or "socket_mode"
+
+    def __init__(
+        self,
+        *,
+        body: Union[str, dict],
+        query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None,
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+        context: Optional[Dict[str, Any]] = None,
+        mode: str = "http",  # either "http" or "socket_mode"
+    ):
+        """Request to a Bolt app.
+
+        Args:
+            body: The raw request body (only plain text is supported for "http" mode)
+            query: The query string data in any data format.
+            headers: The request headers.
+            context: The context in this request.
+            mode: The mode used for this request. (either "http" or "socket_mode")
+        """
+        if mode == "http":
+            # HTTP Mode
+            if body is not None and not isinstance(body, str):
+                raise BoltError(error_message_raw_body_required_in_http_mode())
+            self.raw_body = body if body is not None else ""
+        else:
+            # Socket Mode
+            if body is not None and isinstance(body, str):
+                self.raw_body = body
+            else:
+                # We don't convert the dict value to str
+                # as doing so does not guarantee to keep the original structure/format.
+                self.raw_body = ""
+
+        self.query = parse_query(query)
+        self.headers = build_normalized_headers(headers)
+        self.content_type = extract_content_type(self.headers)
+
+        if isinstance(body, str):
+            self.body = parse_body(self.raw_body, self.content_type)
+        elif isinstance(body, dict):
+            self.body = body
+        else:
+            self.body = {}
+
+        self.context = build_context(BoltContext(context if context else {}), self.body)
+        self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0])
+        self.lazy_function_name = self.headers.get("x-slack-bolt-lazy-function-name", [None])[0]
+        self.mode = mode
+
+    def to_copyable(self) -> "BoltRequest":
+        body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+        return BoltRequest(
+            body=body,
+            query=self.query,
+            headers=self.headers,
+            context=self.context.to_copyable(),
+            mode=self.mode,
+        )
+
+

Request to a Bolt app.

+

Args

+
+
body
+
The raw request body (only plain text is supported for "http" mode)
+
query
+
The query string data in any data format.
+
headers
+
The request headers.
+
context
+
The context in this request.
+
mode
+
The mode used for this request. (either "http" or "socket_mode")
+
+

Class variables

+
+
var body : Dict[str, Any]
+
+

The type of the None singleton.

+
+
var content_type : str | None
+
+

The type of the None singleton.

+
+
var contextBoltContext
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var lazy_function_name : str | None
+
+

The type of the None singleton.

+
+
var lazy_only : bool
+
+

The type of the None singleton.

+
+
var mode : str
+
+

The type of the None singleton.

+
+
var query : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var raw_body : str
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def to_copyable(self) ‑> BoltRequest +
+
+
+ +Expand source code + +
def to_copyable(self) -> "BoltRequest":
+    body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body
+    return BoltRequest(
+        body=body,
+        query=self.query,
+        headers=self.headers,
+        context=self.context.to_copyable(),
+        mode=self.mode,
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/response/index.html b/docs/reference/response/index.html new file mode 100644 index 000000000..a4f4989ee --- /dev/null +++ b/docs/reference/response/index.html @@ -0,0 +1,233 @@ + + + + + + +slack_bolt.response API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.response

+
+
+

This interface represents Bolt's synchronous response to Slack.

+

In Socket Mode, the response data can be transformed to a WebSocket message. In the HTTP endpoint mode, +the response data becomes an HTTP response data.

+

Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections.

+
+
+

Sub-modules

+
+
slack_bolt.response.response
+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BoltResponse +(*,
status: int,
body: str | dict = '',
headers: Dict[str, str | Sequence[str]] | None = None)
+
+
+
+ +Expand source code + +
class BoltResponse:
+    status: int
+    body: str
+    headers: Dict[str, Sequence[str]]
+
+    def __init__(
+        self,
+        *,
+        status: int,
+        body: Union[str, dict] = "",
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+    ):
+        """The response from a Bolt app.
+
+        Args:
+            status: HTTP status code
+            body: The response body (dict and str are supported)
+            headers: The response headers.
+        """
+        self.status: int = status
+        self.body: str = json.dumps(body) if isinstance(body, dict) else body
+        self.headers: Dict[str, Sequence[str]] = {}
+        if headers is not None:
+            for name, value in headers.items():
+                if value is None:
+                    continue
+                if isinstance(value, list):
+                    self.headers[name.lower()] = value
+                elif isinstance(value, set):
+                    self.headers[name.lower()] = list(value)
+                else:
+                    self.headers[name.lower()] = [str(value)]
+
+        if "content-type" not in self.headers.keys():
+            if self.body and self.body.startswith("{"):
+                self.headers["content-type"] = ["application/json;charset=utf-8"]
+            else:
+                self.headers["content-type"] = ["text/plain;charset=utf-8"]
+
+    def first_headers(self) -> Dict[str, str]:
+        return {k: list(v)[0] for k, v in self.headers.items()}
+
+    def first_headers_without_set_cookie(self) -> Dict[str, str]:
+        return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"}
+
+    def cookies(self) -> Sequence[SimpleCookie]:
+        header_values = self.headers.get("set-cookie", [])
+        return [self._to_simple_cookie(v) for v in header_values]
+
+    @staticmethod
+    def _to_simple_cookie(header_value: str) -> SimpleCookie:
+        c = SimpleCookie()
+        c.load(header_value)
+        return c
+
+

The response from a Bolt app.

+

Args

+
+
status
+
HTTP status code
+
body
+
The response body (dict and str are supported)
+
headers
+
The response headers.
+
+

Class variables

+
+
var body : str
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var status : int
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def cookies(self) ‑> Sequence[http.cookies.SimpleCookie] +
+
+
+ +Expand source code + +
def cookies(self) -> Sequence[SimpleCookie]:
+    header_values = self.headers.get("set-cookie", [])
+    return [self._to_simple_cookie(v) for v in header_values]
+
+
+
+
+def first_headers(self) ‑> Dict[str, str] +
+
+
+ +Expand source code + +
def first_headers(self) -> Dict[str, str]:
+    return {k: list(v)[0] for k, v in self.headers.items()}
+
+
+
+ +
+
+ +Expand source code + +
def first_headers_without_set_cookie(self) -> Dict[str, str]:
+    return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"}
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/response/response.html b/docs/reference/response/response.html new file mode 100644 index 000000000..5044254e8 --- /dev/null +++ b/docs/reference/response/response.html @@ -0,0 +1,217 @@ + + + + + + +slack_bolt.response.response API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.response.response

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class BoltResponse +(*,
status: int,
body: str | dict = '',
headers: Dict[str, str | Sequence[str]] | None = None)
+
+
+
+ +Expand source code + +
class BoltResponse:
+    status: int
+    body: str
+    headers: Dict[str, Sequence[str]]
+
+    def __init__(
+        self,
+        *,
+        status: int,
+        body: Union[str, dict] = "",
+        headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None,
+    ):
+        """The response from a Bolt app.
+
+        Args:
+            status: HTTP status code
+            body: The response body (dict and str are supported)
+            headers: The response headers.
+        """
+        self.status: int = status
+        self.body: str = json.dumps(body) if isinstance(body, dict) else body
+        self.headers: Dict[str, Sequence[str]] = {}
+        if headers is not None:
+            for name, value in headers.items():
+                if value is None:
+                    continue
+                if isinstance(value, list):
+                    self.headers[name.lower()] = value
+                elif isinstance(value, set):
+                    self.headers[name.lower()] = list(value)
+                else:
+                    self.headers[name.lower()] = [str(value)]
+
+        if "content-type" not in self.headers.keys():
+            if self.body and self.body.startswith("{"):
+                self.headers["content-type"] = ["application/json;charset=utf-8"]
+            else:
+                self.headers["content-type"] = ["text/plain;charset=utf-8"]
+
+    def first_headers(self) -> Dict[str, str]:
+        return {k: list(v)[0] for k, v in self.headers.items()}
+
+    def first_headers_without_set_cookie(self) -> Dict[str, str]:
+        return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"}
+
+    def cookies(self) -> Sequence[SimpleCookie]:
+        header_values = self.headers.get("set-cookie", [])
+        return [self._to_simple_cookie(v) for v in header_values]
+
+    @staticmethod
+    def _to_simple_cookie(header_value: str) -> SimpleCookie:
+        c = SimpleCookie()
+        c.load(header_value)
+        return c
+
+

The response from a Bolt app.

+

Args

+
+
status
+
HTTP status code
+
body
+
The response body (dict and str are supported)
+
headers
+
The response headers.
+
+

Class variables

+
+
var body : str
+
+

The type of the None singleton.

+
+
var headers : Dict[str, Sequence[str]]
+
+

The type of the None singleton.

+
+
var status : int
+
+

The type of the None singleton.

+
+
+

Methods

+
+
+def cookies(self) ‑> Sequence[http.cookies.SimpleCookie] +
+
+
+ +Expand source code + +
def cookies(self) -> Sequence[SimpleCookie]:
+    header_values = self.headers.get("set-cookie", [])
+    return [self._to_simple_cookie(v) for v in header_values]
+
+
+
+
+def first_headers(self) ‑> Dict[str, str] +
+
+
+ +Expand source code + +
def first_headers(self) -> Dict[str, str]:
+    return {k: list(v)[0] for k, v in self.headers.items()}
+
+
+
+ +
+
+ +Expand source code + +
def first_headers_without_set_cookie(self) -> Dict[str, str]:
+    return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"}
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/util/async_utils.html b/docs/reference/util/async_utils.html new file mode 100644 index 000000000..f74d8f0ac --- /dev/null +++ b/docs/reference/util/async_utils.html @@ -0,0 +1,91 @@ + + + + + + +slack_bolt.util.async_utils API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.util.async_utils

+
+
+
+
+
+
+
+
+

Functions

+
+
+def create_async_web_client(token: str | None = None, logger: logging.Logger | None = None) ‑> slack_sdk.web.async_client.AsyncWebClient +
+
+
+ +Expand source code + +
def create_async_web_client(token: Optional[str] = None, logger: Optional[Logger] = None) -> AsyncWebClient:
+    return AsyncWebClient(
+        token=token,
+        logger=logger,
+        user_agent_prefix=f"Bolt-Async/{bolt_version}",
+    )
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/util/index.html b/docs/reference/util/index.html new file mode 100644 index 000000000..6eadaacb9 --- /dev/null +++ b/docs/reference/util/index.html @@ -0,0 +1,84 @@ + + + + + + +slack_bolt.util API documentation + + + + + + + + + + + +
+ + +
+ + + diff --git a/docs/reference/util/utils.html b/docs/reference/util/utils.html new file mode 100644 index 000000000..85d336513 --- /dev/null +++ b/docs/reference/util/utils.html @@ -0,0 +1,262 @@ + + + + + + +slack_bolt.util.utils API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.util.utils

+
+
+
+
+
+
+
+
+

Functions

+
+
+def convert_to_dict(obj: Dict | slack_sdk.models.basic_objects.JsonObject) ‑> Dict +
+
+
+ +Expand source code + +
def convert_to_dict(obj: Union[Dict, JsonObject]) -> Dict:
+    if isinstance(obj, dict):
+        return obj
+    if isinstance(obj, JsonObject) or hasattr(obj, "to_dict"):
+        return obj.to_dict()
+    raise BoltError(f"{obj} (type: {type(obj)}) is unsupported")
+
+
+
+
+def convert_to_dict_list(objects: Sequence[Dict | slack_sdk.models.basic_objects.JsonObject]) ‑> Sequence[Dict] +
+
+
+ +Expand source code + +
def convert_to_dict_list(objects: Sequence[Union[Dict, JsonObject]]) -> Sequence[Dict]:
+    return [convert_to_dict(elm) for elm in objects]
+
+
+
+
+def create_copy(original: Any) ‑> Any +
+
+
+ +Expand source code + +
def create_copy(original: Any) -> Any:
+    return copy.deepcopy(original)
+
+
+
+
+def create_web_client(token: str | None = None, logger: logging.Logger | None = None) ‑> slack_sdk.web.client.WebClient +
+
+
+ +Expand source code + +
def create_web_client(token: Optional[str] = None, logger: Optional[Logger] = None) -> WebClient:
+    return WebClient(
+        token=token,
+        logger=logger,
+        user_agent_prefix=f"Bolt/{bolt_version}",
+    )
+
+
+
+
+def get_arg_names_of_callable(func: Callable) ‑> List[str] +
+
+
+ +Expand source code + +
def get_arg_names_of_callable(func: Callable) -> List[str]:
+    return inspect.getfullargspec(inspect.unwrap(func)).args
+
+
+
+
+def get_boot_message(development_server: bool = False) ‑> str +
+
+
+ +Expand source code + +
def get_boot_message(development_server: bool = False) -> str:
+    if sys.platform == "win32":
+        # Some Windows environments may fail to parse this str value
+        # and result in UnicodeEncodeError
+        if development_server:
+            return "Bolt app is running! (development server)"
+        else:
+            return "Bolt app is running!"
+
+    try:
+        if development_server:
+            return "⚡️ Bolt app is running! (development server)"
+        else:
+            return "⚡️ Bolt app is running!"
+    except ValueError:
+        # ValueError is a runtime exception for a given value
+        # It's a super class of UnicodeEncodeError, which may be raised in the scenario
+        # see also: https://github.com/slackapi/bolt-python/issues/170
+        if development_server:
+            return "Bolt app is running! (development server)"
+        else:
+            return "Bolt app is running!"
+
+
+
+
+def get_name_for_callable(func: Callable) ‑> str +
+
+
+ +Expand source code + +
def get_name_for_callable(func: Callable) -> str:
+    """Returns the name for the given Callable function object.
+
+    Args:
+        func: Either a `Callable` instance or a function, which as `__name__`
+
+    Returns:
+        The name of the given Callable object
+    """
+    if hasattr(func, "__name__"):
+        return func.__name__
+    else:
+        return f"{func.__class__.__module__}.{func.__class__.__name__}"
+
+

Returns the name for the given Callable function object.

+

Args

+
+
func
+
Either a Callable instance or a function, which as __name__
+
+

Returns

+

The name of the given Callable object

+
+
+def is_callable_coroutine(func: Any | None) ‑> bool +
+
+
+ +Expand source code + +
def is_callable_coroutine(func: Optional[Any]) -> bool:
+    return func is not None and (
+        inspect.iscoroutinefunction(func) or (hasattr(func, "__call__") and inspect.iscoroutinefunction(func.__call__))
+    )
+
+
+
+
+def is_used_without_argument(args) ‑> bool +
+
+
+ +Expand source code + +
def is_used_without_argument(args) -> bool:
+    """Tests if a decorator invocation is without () or (args).
+
+    Args:
+        args: arguments
+
+    Returns:
+        True if it's an invocation without args
+    """
+    return len(args) == 1
+
+

Tests if a decorator invocation is without () or (args).

+

Args

+
+
args
+
arguments
+
+

Returns

+

True if it's an invocation without args

+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/version.html b/docs/reference/version.html new file mode 100644 index 000000000..c4a0f9b83 --- /dev/null +++ b/docs/reference/version.html @@ -0,0 +1,67 @@ + + + + + + +slack_bolt.version API documentation + + + + + + + + + + + +
+ + +
+ + + diff --git a/docs/reference/workflows/index.html b/docs/reference/workflows/index.html new file mode 100644 index 000000000..0dfe7457f --- /dev/null +++ b/docs/reference/workflows/index.html @@ -0,0 +1,86 @@ + + + + + + +slack_bolt.workflows API documentation + + + + + + + + + + + +
+ + +
+ + + diff --git a/docs/reference/workflows/step/async_step.html b/docs/reference/workflows/step/async_step.html new file mode 100644 index 000000000..18fdd3ab9 --- /dev/null +++ b/docs/reference/workflows/step/async_step.html @@ -0,0 +1,1013 @@ + + + + + + +slack_bolt.workflows.step.async_step API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.async_step

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncWorkflowStep +(*,
callback_id: str | Pattern,
edit: Callable[..., Awaitable[BoltResponse]] | AsyncListener | Sequence[Callable],
save: Callable[..., Awaitable[BoltResponse]] | AsyncListener | Sequence[Callable],
execute: Callable[..., Awaitable[BoltResponse]] | AsyncListener | Sequence[Callable],
app_name: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncWorkflowStep:
+    callback_id: Union[str, Pattern]
+    """The Callback ID of the step from app"""
+    edit: AsyncListener
+    """`edit` listener, which displays a modal in Workflow Builder"""
+    save: AsyncListener
+    """`save` listener, which accepts workflow creator's data submission in Workflow Builder"""
+    execute: AsyncListener
+    """`execute` listener, which processes the step from app execution"""
+
+    def __init__(
+        self,
+        *,
+        callback_id: Union[str, Pattern],
+        edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]],
+        save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]],
+        execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]],
+        app_name: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Args:
+            callback_id: The callback_id for this step from app
+            edit: Either a single function or a list of functions for opening a modal in the builder UI
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            save: Either a single function or a list of functions for handling modal interactions in the builder UI
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            execute: Either a single function or a list of functions for handling steps from apps executions
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            app_name: The app name that can be mainly used for logging
+            base_logger: The logger instance that can be used as a template when creating this step's logger
+        """
+        self.callback_id = callback_id
+        app_name = app_name or __name__
+        self.edit = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=edit,
+            name="edit",
+            base_logger=base_logger,
+        )
+        self.save = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=save,
+            name="save",
+            base_logger=base_logger,
+        )
+        self.execute = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=execute,
+            name="execute",
+            base_logger=base_logger,
+        )
+
+    @classmethod
+    def builder(
+        cls,
+        callback_id: Union[str, Pattern],
+        base_logger: Optional[Logger] = None,
+    ) -> AsyncWorkflowStepBuilder:
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+        """
+        return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger)
+
+    @classmethod
+    def build_listener(
+        cls,
+        callback_id: Union[str, Pattern],
+        app_name: str,
+        listener_or_functions: Union[AsyncListener, Callable, List[Callable]],
+        name: str,
+        matchers: Optional[List[AsyncListenerMatcher]] = None,
+        middleware: Optional[List[AsyncMiddleware]] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        if listener_or_functions is None:
+            raise BoltError(f"{name} listener is required (callback_id: {callback_id})")
+
+        if isinstance(listener_or_functions, Callable):
+            listener_or_functions = [listener_or_functions]
+
+        if isinstance(listener_or_functions, AsyncListener):
+            return listener_or_functions
+        elif isinstance(listener_or_functions, list):
+            matchers = matchers if matchers else []
+            matchers.insert(0, cls._build_primary_matcher(name, callback_id, base_logger))
+            middleware = middleware if middleware else []
+            middleware.insert(0, cls._build_single_middleware(name, callback_id, base_logger))
+            functions = listener_or_functions
+            ack_function = functions.pop(0)
+            return AsyncCustomListener(
+                app_name=app_name,
+                matchers=matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=name == "execute",
+                base_logger=base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})")
+
+    @classmethod
+    def _build_primary_matcher(
+        cls,
+        name: str,
+        callback_id: str,
+        base_logger: Optional[Logger] = None,
+    ) -> AsyncListenerMatcher:
+        if name == "edit":
+            return workflow_step_edit(callback_id, asyncio=True, base_logger=base_logger)
+        elif name == "save":
+            return workflow_step_save(callback_id, asyncio=True, base_logger=base_logger)
+        elif name == "execute":
+            return workflow_step_execute(callback_id, asyncio=True, base_logger=base_logger)
+        else:
+            raise ValueError(f"Invalid name {name}")
+
+    @classmethod
+    def _build_single_middleware(
+        cls,
+        name: str,
+        callback_id: str,
+        base_logger: Optional[Logger] = None,
+    ) -> AsyncMiddleware:
+        if name == "edit":
+            return _build_edit_listener_middleware(callback_id, base_logger)
+        elif name == "save":
+            return _build_save_listener_middleware(base_logger)
+        elif name == "execute":
+            return _build_execute_listener_middleware(base_logger)
+        else:
+            raise ValueError(f"Invalid name {name}")
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Args

+
+
callback_id
+
The callback_id for this step from app
+
edit
+
Either a single function or a list of functions for opening a modal in the builder UI +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
save
+
Either a single function or a list of functions for handling modal interactions in the builder UI +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
execute
+
Either a single function or a list of functions for handling steps from apps executions +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
app_name
+
The app name that can be mainly used for logging
+
base_logger
+
The logger instance that can be used as a template when creating this step's logger
+
+

Class variables

+
+
var callback_id : str | Pattern
+
+

The Callback ID of the step from app

+
+
var editAsyncListener
+
+

edit listener, which displays a modal in Workflow Builder

+
+
var executeAsyncListener
+
+

execute listener, which processes the step from app execution

+
+
var saveAsyncListener
+
+

save listener, which accepts workflow creator's data submission in Workflow Builder

+
+
+

Static methods

+
+
+def build_listener(callback_id: str | Pattern,
app_name: str,
listener_or_functions: AsyncListener | Callable | List[Callable],
name: str,
matchers: List[AsyncListenerMatcher] | None = None,
middleware: List[AsyncMiddleware] | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+
+
+def builder(callback_id: str | Pattern, base_logger: logging.Logger | None = None) ‑> AsyncWorkflowStepBuilder +
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+
+
+
+
+class AsyncWorkflowStepBuilder +(callback_id: str | Pattern,
app_name: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class AsyncWorkflowStepBuilder:
+    """Steps from apps
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
+    """
+
+    callback_id: Union[str, Pattern]
+    _base_logger: Optional[Logger]
+    _edit: Optional[AsyncListener]
+    _save: Optional[AsyncListener]
+    _execute: Optional[AsyncListener]
+
+    def __init__(
+        self,
+        callback_id: Union[str, Pattern],
+        app_name: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        This builder is supposed to be used as decorator.
+
+            my_step = AsyncWorkflowStep.builder("my_step")
+            @my_step.edit
+            async def edit_my_step(ack, configure):
+                pass
+            @my_step.save
+            async def save_my_step(ack, step, update):
+                pass
+            @my_step.execute
+            async def execute_my_step(step, complete, fail):
+                pass
+            app.step(my_step)
+
+        For further information about AsyncWorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            callback_id: The callback_id for the workflow
+            app_name: The application name mainly for logging
+            base_logger: The base logger
+        """
+        self.callback_id = callback_id
+        self.app_name = app_name or __name__
+        self._base_logger = base_logger
+        self._edit = None
+        self._save = None
+        self._execute = None
+
+    def edit(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., Awaitable[None]]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new edit listener with details.
+
+        You can use this method as decorator as well.
+
+            @my_step.edit
+            def edit_my_step(ack, configure):
+                pass
+
+        It's also possible to add additional listener matchers and/or middleware
+
+            @my_step.edit(matchers=[is_valid], middleware=[update_context])
+            def edit_my_step(ack, configure):
+                pass
+
+        For further information about AsyncWorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            *args: This method can behave as either decorator or a method
+            matchers: Listener matchers
+            middleware: Listener middleware
+            lazy: Lazy listeners
+        """
+        if _is_used_without_argument(args):
+            func = args[0]
+            self._edit = self._to_listener("edit", func, matchers, middleware)
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._edit = self._to_listener("edit", functions, matchers, middleware)
+
+            @wraps(func)
+            async def _wrapper(*args, **kwargs):
+                return await func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def save(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., Awaitable[None]]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new save listener with details.
+
+        You can use this method as decorator as well.
+
+            @my_step.save
+            def save_my_step(ack, step, update):
+                pass
+
+        It's also possible to add additional listener matchers and/or middleware
+
+            @my_step.save(matchers=[is_valid], middleware=[update_context])
+            def save_my_step(ack, step, update):
+                pass
+
+        For further information about AsyncWorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            *args: This method can behave as either decorator or a method
+            matchers: Listener matchers
+            middleware: Listener middleware
+            lazy: Lazy listeners
+        """
+        if _is_used_without_argument(args):
+            func = args[0]
+            self._save = self._to_listener("save", func, matchers, middleware)
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._save = self._to_listener("save", functions, matchers, middleware)
+
+            @wraps(func)
+            async def _wrapper(*args, **kwargs):
+                return await func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def execute(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+        lazy: Optional[List[Callable[..., Awaitable[None]]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new execute listener with details.
+
+        You can use this method as decorator as well.
+
+            @my_step.execute
+            def execute_my_step(step, complete, fail):
+                pass
+
+        It's also possible to add additional listener matchers and/or middleware
+
+            @my_step.save(matchers=[is_valid], middleware=[update_context])
+            def execute_my_step(step, complete, fail):
+                pass
+
+        For further information about AsyncWorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            *args: This method can behave as either decorator or a method
+            matchers: Listener matchers
+            middleware: Listener middleware
+            lazy: Lazy listeners
+        """
+        if _is_used_without_argument(args):
+            func = args[0]
+            self._execute = self._to_listener("execute", func, matchers, middleware)
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._execute = self._to_listener("execute", functions, matchers, middleware)
+
+            @wraps(func)
+            async def _wrapper(*args, **kwargs):
+                return await func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep":
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Constructs a WorkflowStep object. This method may raise an exception
+        if the builder doesn't have enough configurations to build the object.
+
+        Returns:
+            An `AsyncWorkflowStep` object
+        """
+        if self._edit is None:
+            raise BoltError("edit listener is not registered")
+        if self._save is None:
+            raise BoltError("save listener is not registered")
+        if self._execute is None:
+            raise BoltError("execute listener is not registered")
+
+        return AsyncWorkflowStep(
+            callback_id=self.callback_id,
+            edit=self._edit,
+            save=self._save,
+            execute=self._execute,
+            app_name=self.app_name,
+            base_logger=base_logger,
+        )
+
+    # ---------------------------------------
+
+    def _to_listener(
+        self,
+        name: str,
+        listener_or_functions: Union[AsyncListener, Callable, List[Callable]],
+        matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    ) -> AsyncListener:
+        return AsyncWorkflowStep.build_listener(
+            callback_id=self.callback_id,
+            app_name=self.app_name,
+            listener_or_functions=listener_or_functions,
+            name=name,
+            matchers=self.to_listener_matchers(self.app_name, matchers),
+            middleware=self.to_listener_middleware(self.app_name, middleware),
+            base_logger=self._base_logger,
+        )
+
+    @staticmethod
+    def to_listener_matchers(
+        app_name: str,
+        matchers: Optional[List[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]]],
+    ) -> List[AsyncListenerMatcher]:
+        _matchers = []
+        if matchers is not None:
+            for m in matchers:
+                if isinstance(m, AsyncListenerMatcher):
+                    _matchers.append(m)
+                elif isinstance(m, Callable):
+                    _matchers.append(AsyncCustomListenerMatcher(app_name=app_name, func=m))
+                else:
+                    raise ValueError(f"Invalid matcher: {type(m)}")
+        return _matchers
+
+    @staticmethod
+    def to_listener_middleware(
+        app_name: str, middleware: Optional[List[Union[Callable, AsyncMiddleware]]]
+    ) -> List[AsyncMiddleware]:
+        _middleware = []
+        if middleware is not None:
+            for m in middleware:
+                if isinstance(m, AsyncMiddleware):
+                    _middleware.append(m)
+                elif isinstance(m, Callable):
+                    _middleware.append(AsyncCustomMiddleware(app_name=app_name, func=m))
+                else:
+                    raise ValueError(f"Invalid middleware: {type(m)}")
+        return _middleware
+
+

Steps from apps +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

This builder is supposed to be used as decorator.

+
my_step = AsyncWorkflowStep.builder("my_step")
+@my_step.edit
+async def edit_my_step(ack, configure):
+    pass
+@my_step.save
+async def save_my_step(ack, step, update):
+    pass
+@my_step.execute
+async def execute_my_step(step, complete, fail):
+    pass
+app.step(my_step)
+
+

For further information about AsyncWorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to the async prefixed ones in slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
callback_id
+
The callback_id for the workflow
+
app_name
+
The application name mainly for logging
+
base_logger
+
The base logger
+
+

Class variables

+
+
var callback_id : str | Pattern
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def to_listener_matchers(app_name: str,
matchers: List[AsyncListenerMatcher | Callable[..., Awaitable[bool]]] | None) ‑> List[AsyncListenerMatcher]
+
+
+
+ +Expand source code + +
@staticmethod
+def to_listener_matchers(
+    app_name: str,
+    matchers: Optional[List[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]]],
+) -> List[AsyncListenerMatcher]:
+    _matchers = []
+    if matchers is not None:
+        for m in matchers:
+            if isinstance(m, AsyncListenerMatcher):
+                _matchers.append(m)
+            elif isinstance(m, Callable):
+                _matchers.append(AsyncCustomListenerMatcher(app_name=app_name, func=m))
+            else:
+                raise ValueError(f"Invalid matcher: {type(m)}")
+    return _matchers
+
+
+
+
+def to_listener_middleware(app_name: str,
middleware: List[Callable | AsyncMiddleware] | None) ‑> List[AsyncMiddleware]
+
+
+
+ +Expand source code + +
@staticmethod
+def to_listener_middleware(
+    app_name: str, middleware: Optional[List[Union[Callable, AsyncMiddleware]]]
+) -> List[AsyncMiddleware]:
+    _middleware = []
+    if middleware is not None:
+        for m in middleware:
+            if isinstance(m, AsyncMiddleware):
+                _middleware.append(m)
+            elif isinstance(m, Callable):
+                _middleware.append(AsyncCustomMiddleware(app_name=app_name, func=m))
+            else:
+                raise ValueError(f"Invalid middleware: {type(m)}")
+    return _middleware
+
+
+
+
+

Methods

+
+
+def build(self, base_logger: logging.Logger | None = None) ‑> AsyncWorkflowStep +
+
+
+ +Expand source code + +
def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep":
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Constructs a WorkflowStep object. This method may raise an exception
+    if the builder doesn't have enough configurations to build the object.
+
+    Returns:
+        An `AsyncWorkflowStep` object
+    """
+    if self._edit is None:
+        raise BoltError("edit listener is not registered")
+    if self._save is None:
+        raise BoltError("save listener is not registered")
+    if self._execute is None:
+        raise BoltError("execute listener is not registered")
+
+    return AsyncWorkflowStep(
+        callback_id=self.callback_id,
+        edit=self._edit,
+        save=self._save,
+        execute=self._execute,
+        app_name=self.app_name,
+        base_logger=base_logger,
+    )
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Constructs a WorkflowStep object. This method may raise an exception +if the builder doesn't have enough configurations to build the object.

+

Returns

+

An AsyncWorkflowStep object

+
+
+def edit(self,
*args,
matchers: Callable[..., Awaitable[bool]] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., Awaitable[None]]] | None = None)
+
+
+
+ +Expand source code + +
def edit(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., Awaitable[None]]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new edit listener with details.
+
+    You can use this method as decorator as well.
+
+        @my_step.edit
+        def edit_my_step(ack, configure):
+            pass
+
+    It's also possible to add additional listener matchers and/or middleware
+
+        @my_step.edit(matchers=[is_valid], middleware=[update_context])
+        def edit_my_step(ack, configure):
+            pass
+
+    For further information about AsyncWorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        *args: This method can behave as either decorator or a method
+        matchers: Listener matchers
+        middleware: Listener middleware
+        lazy: Lazy listeners
+    """
+    if _is_used_without_argument(args):
+        func = args[0]
+        self._edit = self._to_listener("edit", func, matchers, middleware)
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._edit = self._to_listener("edit", functions, matchers, middleware)
+
+        @wraps(func)
+        async def _wrapper(*args, **kwargs):
+            return await func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new edit listener with details.

+

You can use this method as decorator as well.

+
@my_step.edit
+def edit_my_step(ack, configure):
+    pass
+
+

It's also possible to add additional listener matchers and/or middleware

+
@my_step.edit(matchers=[is_valid], middleware=[update_context])
+def edit_my_step(ack, configure):
+    pass
+
+

For further information about AsyncWorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to the async prefixed ones in slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
*args
+
This method can behave as either decorator or a method
+
matchers
+
Listener matchers
+
middleware
+
Listener middleware
+
lazy
+
Lazy listeners
+
+
+
+def execute(self,
*args,
matchers: Callable[..., Awaitable[bool]] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., Awaitable[None]]] | None = None)
+
+
+
+ +Expand source code + +
def execute(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., Awaitable[None]]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new execute listener with details.
+
+    You can use this method as decorator as well.
+
+        @my_step.execute
+        def execute_my_step(step, complete, fail):
+            pass
+
+    It's also possible to add additional listener matchers and/or middleware
+
+        @my_step.save(matchers=[is_valid], middleware=[update_context])
+        def execute_my_step(step, complete, fail):
+            pass
+
+    For further information about AsyncWorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        *args: This method can behave as either decorator or a method
+        matchers: Listener matchers
+        middleware: Listener middleware
+        lazy: Lazy listeners
+    """
+    if _is_used_without_argument(args):
+        func = args[0]
+        self._execute = self._to_listener("execute", func, matchers, middleware)
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._execute = self._to_listener("execute", functions, matchers, middleware)
+
+        @wraps(func)
+        async def _wrapper(*args, **kwargs):
+            return await func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new execute listener with details.

+

You can use this method as decorator as well.

+
@my_step.execute
+def execute_my_step(step, complete, fail):
+    pass
+
+

It's also possible to add additional listener matchers and/or middleware

+
@my_step.save(matchers=[is_valid], middleware=[update_context])
+def execute_my_step(step, complete, fail):
+    pass
+
+

For further information about AsyncWorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to the async prefixed ones in slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
*args
+
This method can behave as either decorator or a method
+
matchers
+
Listener matchers
+
middleware
+
Listener middleware
+
lazy
+
Lazy listeners
+
+
+
+def save(self,
*args,
matchers: Callable[..., Awaitable[bool]] | AsyncListenerMatcher | None = None,
middleware: Callable | AsyncMiddleware | None = None,
lazy: List[Callable[..., Awaitable[None]]] | None = None)
+
+
+
+ +Expand source code + +
def save(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, AsyncMiddleware]] = None,
+    lazy: Optional[List[Callable[..., Awaitable[None]]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new save listener with details.
+
+    You can use this method as decorator as well.
+
+        @my_step.save
+        def save_my_step(ack, step, update):
+            pass
+
+    It's also possible to add additional listener matchers and/or middleware
+
+        @my_step.save(matchers=[is_valid], middleware=[update_context])
+        def save_my_step(ack, step, update):
+            pass
+
+    For further information about AsyncWorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        *args: This method can behave as either decorator or a method
+        matchers: Listener matchers
+        middleware: Listener middleware
+        lazy: Lazy listeners
+    """
+    if _is_used_without_argument(args):
+        func = args[0]
+        self._save = self._to_listener("save", func, matchers, middleware)
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._save = self._to_listener("save", functions, matchers, middleware)
+
+        @wraps(func)
+        async def _wrapper(*args, **kwargs):
+            return await func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new save listener with details.

+

You can use this method as decorator as well.

+
@my_step.save
+def save_my_step(ack, step, update):
+    pass
+
+

It's also possible to add additional listener matchers and/or middleware

+
@my_step.save(matchers=[is_valid], middleware=[update_context])
+def save_my_step(ack, step, update):
+    pass
+
+

For further information about AsyncWorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to the async prefixed ones in slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
*args
+
This method can behave as either decorator or a method
+
matchers
+
Listener matchers
+
middleware
+
Listener middleware
+
lazy
+
Lazy listeners
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/async_step_middleware.html b/docs/reference/workflows/step/async_step_middleware.html new file mode 100644 index 000000000..a174b9c11 --- /dev/null +++ b/docs/reference/workflows/step/async_step_middleware.html @@ -0,0 +1,146 @@ + + + + + + +slack_bolt.workflows.step.async_step_middleware API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.async_step_middleware

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncWorkflowStepMiddleware +(step: AsyncWorkflowStep) +
+
+
+ +Expand source code + +
class AsyncWorkflowStepMiddleware(AsyncMiddleware):
+    """Base middleware for step from app specific ones"""
+
+    def __init__(self, step: AsyncWorkflowStep):
+        self.step = step
+
+    async def async_process(
+        self,
+        *,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+        next: Callable[[], Awaitable[BoltResponse]],
+    ) -> BoltResponse:
+
+        if await self.step.edit.async_matches(req=req, resp=resp):
+            resp = await self._run(self.step.edit, req, resp)
+            if resp is not None:
+                return resp
+        elif await self.step.save.async_matches(req=req, resp=resp):
+            resp = await self._run(self.step.save, req, resp)
+            if resp is not None:
+                return resp
+        elif await self.step.execute.async_matches(req=req, resp=resp):
+            resp = await self._run(self.step.execute, req, resp)
+            if resp is not None:
+                return resp
+
+        return await next()
+
+    @staticmethod
+    async def _run(
+        listener: AsyncListener,
+        req: AsyncBoltRequest,
+        resp: BoltResponse,
+    ) -> Optional[BoltResponse]:
+        resp, next_was_not_called = await listener.run_async_middleware(req=req, resp=resp)
+        if next_was_not_called:
+            return None
+
+        return await req.context.listener_runner.run(
+            request=req,
+            response=resp,
+            listener_name=get_name_for_callable(listener.ack_function),
+            listener=listener,
+        )
+
+

Base middleware for step from app specific ones

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/index.html b/docs/reference/workflows/step/index.html new file mode 100644 index 000000000..50b52906b --- /dev/null +++ b/docs/reference/workflows/step/index.html @@ -0,0 +1,738 @@ + + + + + + +slack_bolt.workflows.step API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step

+
+
+
+
+

Sub-modules

+
+
slack_bolt.workflows.step.async_step
+
+
+
+
slack_bolt.workflows.step.async_step_middleware
+
+
+
+
slack_bolt.workflows.step.internals
+
+
+
+
slack_bolt.workflows.step.step
+
+
+
+
slack_bolt.workflows.step.step_middleware
+
+
+
+
slack_bolt.workflows.step.utilities
+
+

Utilities specific to steps from apps …

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Complete +(*, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Complete:
+    """`complete()` utility to tell Slack the completion of a step from app execution.
+
+        def execute(step, complete, fail):
+            inputs = step["inputs"]
+            # if everything was successful
+            outputs = {
+                "task_name": inputs["task_name"]["value"],
+                "task_description": inputs["task_description"]["value"],
+            }
+            complete(outputs=outputs)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepCompleted API method.
+    Refer to https://api.slack.com/methods/workflows.stepCompleted for details.
+    """
+
+    def __init__(self, *, client: WebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    def __call__(self, **kwargs) -> None:
+        self.client.workflows_stepCompleted(
+            workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"],
+            **kwargs,
+        )
+
+

complete() utility to tell Slack the completion of a step from app execution.

+
def execute(step, complete, fail):
+    inputs = step["inputs"]
+    # if everything was successful
+    outputs = {
+        "task_name": inputs["task_name"]["value"],
+        "task_description": inputs["task_description"]["value"],
+    }
+    complete(outputs=outputs)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepCompleted API method. +Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

+
+
+class Configure +(*, callback_id: str, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Configure:
+    """`configure()` utility to send the modal view in Workflow Builder.
+
+        def edit(ack, step, configure):
+            ack()
+
+            blocks = [
+                {
+                    "type": "input",
+                    "block_id": "task_name_input",
+                    "element": {
+                        "type": "plain_text_input",
+                        "action_id": "name",
+                        "placeholder": {"type": "plain_text", "text": "Add a task name"},
+                    },
+                    "label": {"type": "plain_text", "text": "Task name"},
+                },
+            ]
+            configure(blocks=blocks)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
+    """
+
+    def __init__(self, *, callback_id: str, client: WebClient, body: dict):
+        self.callback_id = callback_id
+        self.client = client
+        self.body = body
+
+    def __call__(self, *, blocks: Optional[Sequence[Union[dict, Block]]] = None, **kwargs) -> None:
+        self.client.views_open(
+            trigger_id=self.body["trigger_id"],
+            view={
+                "type": "workflow_step",
+                "callback_id": self.callback_id,
+                "blocks": blocks,
+                **kwargs,
+            },
+        )
+
+

configure() utility to send the modal view in Workflow Builder.

+
def edit(ack, step, configure):
+    ack()
+
+    blocks = [
+        {
+            "type": "input",
+            "block_id": "task_name_input",
+            "element": {
+                "type": "plain_text_input",
+                "action_id": "name",
+                "placeholder": {"type": "plain_text", "text": "Add a task name"},
+            },
+            "label": {"type": "plain_text", "text": "Task name"},
+        },
+    ]
+    configure(blocks=blocks)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

+
+
+class Fail +(*, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Fail:
+    """`fail()` utility to tell Slack the execution failure of a step from app.
+
+        def execute(step, complete, fail):
+            inputs = step["inputs"]
+            # if something went wrong
+            error = {"message": "Just testing step failure!"}
+            fail(error=error)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepFailed API method.
+    Refer to https://api.slack.com/methods/workflows.stepFailed for details.
+    """
+
+    def __init__(self, *, client: WebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    def __call__(
+        self,
+        *,
+        error: dict,
+    ) -> None:
+        self.client.workflows_stepFailed(
+            workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"],
+            error=error,
+        )
+
+

fail() utility to tell Slack the execution failure of a step from app.

+
def execute(step, complete, fail):
+    inputs = step["inputs"]
+    # if something went wrong
+    error = {"message": "Just testing step failure!"}
+    fail(error=error)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepFailed API method. +Refer to https://api.slack.com/methods/workflows.stepFailed for details.

+
+
+class Update +(*, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Update:
+    """`update()` utility to tell Slack the processing results of a `save` listener.
+
+        def save(ack, view, update):
+            ack()
+
+            values = view["state"]["values"]
+            task_name = values["task_name_input"]["name"]
+            task_description = values["task_description_input"]["description"]
+
+            inputs = {
+                "task_name": {"value": task_name["value"]},
+                "task_description": {"value": task_description["value"]}
+            }
+            outputs = [
+                {
+                    "type": "text",
+                    "name": "task_name",
+                    "label": "Task name",
+                },
+                {
+                    "type": "text",
+                    "name": "task_description",
+                    "label": "Task description",
+                }
+            ]
+            update(inputs=inputs, outputs=outputs)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepFailed API method.
+    Refer to https://api.slack.com/methods/workflows.updateStep for details.
+    """
+
+    def __init__(self, *, client: WebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    def __call__(self, **kwargs) -> None:
+        self.client.workflows_updateStep(
+            workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"],
+            **kwargs,
+        )
+
+

update() utility to tell Slack the processing results of a save listener.

+
def save(ack, view, update):
+    ack()
+
+    values = view["state"]["values"]
+    task_name = values["task_name_input"]["name"]
+    task_description = values["task_description_input"]["description"]
+
+    inputs = {
+        "task_name": {"value": task_name["value"]},
+        "task_description": {"value": task_description["value"]}
+    }
+    outputs = [
+        {
+            "type": "text",
+            "name": "task_name",
+            "label": "Task name",
+        },
+        {
+            "type": "text",
+            "name": "task_description",
+            "label": "Task description",
+        }
+    ]
+    update(inputs=inputs, outputs=outputs)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepFailed API method. +Refer to https://api.slack.com/methods/workflows.updateStep for details.

+
+
+class WorkflowStep +(*,
callback_id: str | Pattern,
edit: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
save: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
execute: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
app_name: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class WorkflowStep:
+    callback_id: Union[str, Pattern]
+    """The Callback ID of the step from app"""
+    edit: Listener
+    """`edit` listener, which displays a modal in Workflow Builder"""
+    save: Listener
+    """`save` listener, which accepts workflow creator's data submission in Workflow Builder"""
+    execute: Listener
+    """`execute` listener, which processes step from app execution"""
+
+    def __init__(
+        self,
+        *,
+        callback_id: Union[str, Pattern],
+        edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]],
+        save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]],
+        execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]],
+        app_name: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Args:
+            callback_id: The callback_id for this step from app
+            edit: Either a single function or a list of functions for opening a modal in the builder UI
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            save: Either a single function or a list of functions for handling modal interactions in the builder UI
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            execute: Either a single function or a list of functions for handling step from app executions
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            app_name: The app name that can be mainly used for logging
+            base_logger: The logger instance that can be used as a template when creating this step's logger
+        """
+        self.callback_id = callback_id
+        app_name = app_name or __name__
+        self.edit = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=edit,
+            name="edit",
+            base_logger=base_logger,
+        )
+        self.save = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=save,
+            name="save",
+            base_logger=base_logger,
+        )
+        self.execute = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=execute,
+            name="execute",
+            base_logger=base_logger,
+        )
+
+    @classmethod
+    def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder:
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+        """
+        return WorkflowStepBuilder(
+            callback_id,
+            base_logger=base_logger,
+        )
+
+    @classmethod
+    def build_listener(
+        cls,
+        callback_id: Union[str, Pattern],
+        app_name: str,
+        listener_or_functions: Union[Listener, Callable, List[Callable]],
+        name: str,
+        matchers: Optional[List[ListenerMatcher]] = None,
+        middleware: Optional[List[Middleware]] = None,
+        base_logger: Optional[Logger] = None,
+    ) -> Listener:
+        if listener_or_functions is None:
+            raise BoltError(f"{name} listener is required (callback_id: {callback_id})")
+
+        if isinstance(listener_or_functions, Callable):
+            listener_or_functions = [listener_or_functions]
+
+        if isinstance(listener_or_functions, Listener):
+            return listener_or_functions
+        elif isinstance(listener_or_functions, list):
+            matchers = matchers if matchers else []
+            matchers.insert(
+                0,
+                cls._build_primary_matcher(
+                    name,
+                    callback_id,
+                    base_logger=base_logger,
+                ),
+            )
+            middleware = middleware if middleware else []
+            middleware.insert(
+                0,
+                cls._build_single_middleware(
+                    name,
+                    callback_id,
+                    base_logger=base_logger,
+                ),
+            )
+            functions = listener_or_functions
+            ack_function = functions.pop(0)
+            return CustomListener(
+                app_name=app_name,
+                matchers=matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=name == "execute",
+                base_logger=base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})")
+
+    @classmethod
+    def _build_primary_matcher(
+        cls,
+        name: str,
+        callback_id: Union[str, Pattern],
+        base_logger: Optional[Logger] = None,
+    ) -> ListenerMatcher:
+        if name == "edit":
+            return workflow_step_edit(callback_id, base_logger=base_logger)
+        elif name == "save":
+            return workflow_step_save(callback_id, base_logger=base_logger)
+        elif name == "execute":
+            return workflow_step_execute(callback_id, base_logger=base_logger)
+        else:
+            raise ValueError(f"Invalid name {name}")
+
+    @classmethod
+    def _build_single_middleware(
+        cls,
+        name: str,
+        callback_id: Union[str, Pattern],
+        base_logger: Optional[Logger] = None,
+    ) -> Middleware:
+        if name == "edit":
+            return _build_edit_listener_middleware(callback_id, base_logger=base_logger)
+        elif name == "save":
+            return _build_save_listener_middleware(base_logger=base_logger)
+        elif name == "execute":
+            return _build_execute_listener_middleware(base_logger=base_logger)
+        else:
+            raise ValueError(f"Invalid name {name}")
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Args

+
+
callback_id
+
The callback_id for this step from app
+
edit
+
Either a single function or a list of functions for opening a modal in the builder UI +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
save
+
Either a single function or a list of functions for handling modal interactions in the builder UI +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
execute
+
Either a single function or a list of functions for handling step from app executions +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
app_name
+
The app name that can be mainly used for logging
+
base_logger
+
The logger instance that can be used as a template when creating this step's logger
+
+

Class variables

+
+
var callback_id : str | Pattern
+
+

The Callback ID of the step from app

+
+
var editListener
+
+

edit listener, which displays a modal in Workflow Builder

+
+
var executeListener
+
+

execute listener, which processes step from app execution

+
+
var saveListener
+
+

save listener, which accepts workflow creator's data submission in Workflow Builder

+
+
+

Static methods

+
+
+def build_listener(callback_id: str | Pattern,
app_name: str,
listener_or_functions: Listener | Callable | List[Callable],
name: str,
matchers: List[ListenerMatcher] | None = None,
middleware: List[Middleware] | None = None,
base_logger: logging.Logger | None = None) ‑> Listener
+
+
+
+
+
+def builder(callback_id: str | Pattern, base_logger: logging.Logger | None = None) ‑> WorkflowStepBuilder +
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+
+
+
+
+class WorkflowStepMiddleware +(step: WorkflowStep) +
+
+
+ +Expand source code + +
class WorkflowStepMiddleware(Middleware):
+    """Base middleware for step from app specific ones"""
+
+    def __init__(self, step: WorkflowStep):
+        self.step = step
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> Optional[BoltResponse]:
+
+        if self.step.edit.matches(req=req, resp=resp):
+            resp = self._run(self.step.edit, req, resp)
+            if resp is not None:
+                return resp
+        elif self.step.save.matches(req=req, resp=resp):
+            resp = self._run(self.step.save, req, resp)
+            if resp is not None:
+                return resp
+        elif self.step.execute.matches(req=req, resp=resp):
+            resp = self._run(self.step.execute, req, resp)
+            if resp is not None:
+                return resp
+
+        return next()
+
+    @staticmethod
+    def _run(
+        listener: Listener,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> Optional[BoltResponse]:
+        resp, next_was_not_called = listener.run_middleware(req=req, resp=resp)
+        if next_was_not_called:
+            return None
+
+        return req.context.listener_runner.run(
+            request=req,
+            response=resp,
+            listener_name=get_name_for_callable(listener.ack_function),
+            listener=listener,
+        )
+
+

Base middleware for step from app specific ones

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/internals.html b/docs/reference/workflows/step/internals.html new file mode 100644 index 000000000..c5fda1012 --- /dev/null +++ b/docs/reference/workflows/step/internals.html @@ -0,0 +1,66 @@ + + + + + + +slack_bolt.workflows.step.internals API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.internals

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/step.html b/docs/reference/workflows/step/step.html new file mode 100644 index 000000000..0309acd88 --- /dev/null +++ b/docs/reference/workflows/step/step.html @@ -0,0 +1,1058 @@ + + + + + + +slack_bolt.workflows.step.step API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.step

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class WorkflowStep +(*,
callback_id: str | Pattern,
edit: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
save: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
execute: Callable[..., BoltResponse | None] | Listener | Sequence[Callable],
app_name: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class WorkflowStep:
+    callback_id: Union[str, Pattern]
+    """The Callback ID of the step from app"""
+    edit: Listener
+    """`edit` listener, which displays a modal in Workflow Builder"""
+    save: Listener
+    """`save` listener, which accepts workflow creator's data submission in Workflow Builder"""
+    execute: Listener
+    """`execute` listener, which processes step from app execution"""
+
+    def __init__(
+        self,
+        *,
+        callback_id: Union[str, Pattern],
+        edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]],
+        save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]],
+        execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]],
+        app_name: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Args:
+            callback_id: The callback_id for this step from app
+            edit: Either a single function or a list of functions for opening a modal in the builder UI
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            save: Either a single function or a list of functions for handling modal interactions in the builder UI
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            execute: Either a single function or a list of functions for handling step from app executions
+                When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+            app_name: The app name that can be mainly used for logging
+            base_logger: The logger instance that can be used as a template when creating this step's logger
+        """
+        self.callback_id = callback_id
+        app_name = app_name or __name__
+        self.edit = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=edit,
+            name="edit",
+            base_logger=base_logger,
+        )
+        self.save = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=save,
+            name="save",
+            base_logger=base_logger,
+        )
+        self.execute = self.build_listener(
+            callback_id=callback_id,
+            app_name=app_name,
+            listener_or_functions=execute,
+            name="execute",
+            base_logger=base_logger,
+        )
+
+    @classmethod
+    def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder:
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+        """
+        return WorkflowStepBuilder(
+            callback_id,
+            base_logger=base_logger,
+        )
+
+    @classmethod
+    def build_listener(
+        cls,
+        callback_id: Union[str, Pattern],
+        app_name: str,
+        listener_or_functions: Union[Listener, Callable, List[Callable]],
+        name: str,
+        matchers: Optional[List[ListenerMatcher]] = None,
+        middleware: Optional[List[Middleware]] = None,
+        base_logger: Optional[Logger] = None,
+    ) -> Listener:
+        if listener_or_functions is None:
+            raise BoltError(f"{name} listener is required (callback_id: {callback_id})")
+
+        if isinstance(listener_or_functions, Callable):
+            listener_or_functions = [listener_or_functions]
+
+        if isinstance(listener_or_functions, Listener):
+            return listener_or_functions
+        elif isinstance(listener_or_functions, list):
+            matchers = matchers if matchers else []
+            matchers.insert(
+                0,
+                cls._build_primary_matcher(
+                    name,
+                    callback_id,
+                    base_logger=base_logger,
+                ),
+            )
+            middleware = middleware if middleware else []
+            middleware.insert(
+                0,
+                cls._build_single_middleware(
+                    name,
+                    callback_id,
+                    base_logger=base_logger,
+                ),
+            )
+            functions = listener_or_functions
+            ack_function = functions.pop(0)
+            return CustomListener(
+                app_name=app_name,
+                matchers=matchers,
+                middleware=middleware,
+                ack_function=ack_function,
+                lazy_functions=functions,
+                auto_acknowledgement=name == "execute",
+                base_logger=base_logger,
+            )
+        else:
+            raise BoltError(f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})")
+
+    @classmethod
+    def _build_primary_matcher(
+        cls,
+        name: str,
+        callback_id: Union[str, Pattern],
+        base_logger: Optional[Logger] = None,
+    ) -> ListenerMatcher:
+        if name == "edit":
+            return workflow_step_edit(callback_id, base_logger=base_logger)
+        elif name == "save":
+            return workflow_step_save(callback_id, base_logger=base_logger)
+        elif name == "execute":
+            return workflow_step_execute(callback_id, base_logger=base_logger)
+        else:
+            raise ValueError(f"Invalid name {name}")
+
+    @classmethod
+    def _build_single_middleware(
+        cls,
+        name: str,
+        callback_id: Union[str, Pattern],
+        base_logger: Optional[Logger] = None,
+    ) -> Middleware:
+        if name == "edit":
+            return _build_edit_listener_middleware(callback_id, base_logger=base_logger)
+        elif name == "save":
+            return _build_save_listener_middleware(base_logger=base_logger)
+        elif name == "execute":
+            return _build_execute_listener_middleware(base_logger=base_logger)
+        else:
+            raise ValueError(f"Invalid name {name}")
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Args

+
+
callback_id
+
The callback_id for this step from app
+
edit
+
Either a single function or a list of functions for opening a modal in the builder UI +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
save
+
Either a single function or a list of functions for handling modal interactions in the builder UI +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
execute
+
Either a single function or a list of functions for handling step from app executions +When it's a list, the first one is responsible for ack() while the rest are lazy listeners.
+
app_name
+
The app name that can be mainly used for logging
+
base_logger
+
The logger instance that can be used as a template when creating this step's logger
+
+

Class variables

+
+
var callback_id : str | Pattern
+
+

The Callback ID of the step from app

+
+
var editListener
+
+

edit listener, which displays a modal in Workflow Builder

+
+
var executeListener
+
+

execute listener, which processes step from app execution

+
+
var saveListener
+
+

save listener, which accepts workflow creator's data submission in Workflow Builder

+
+
+

Static methods

+
+
+def build_listener(callback_id: str | Pattern,
app_name: str,
listener_or_functions: Listener | Callable | List[Callable],
name: str,
matchers: List[ListenerMatcher] | None = None,
middleware: List[Middleware] | None = None,
base_logger: logging.Logger | None = None) ‑> Listener
+
+
+
+
+
+def builder(callback_id: str | Pattern, base_logger: logging.Logger | None = None) ‑> WorkflowStepBuilder +
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+
+
+
+
+class WorkflowStepBuilder +(callback_id: str | Pattern,
app_name: str | None = None,
base_logger: logging.Logger | None = None)
+
+
+
+ +Expand source code + +
class WorkflowStepBuilder:
+    """Steps from apps
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
+    """
+
+    callback_id: Union[str, Pattern]
+    _base_logger: Optional[Logger]
+    _edit: Optional[Listener]
+    _save: Optional[Listener]
+    _execute: Optional[Listener]
+
+    def __init__(
+        self,
+        callback_id: Union[str, Pattern],
+        app_name: Optional[str] = None,
+        base_logger: Optional[Logger] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        This builder is supposed to be used as decorator.
+
+            my_step = WorkflowStep.builder("my_step")
+            @my_step.edit
+            def edit_my_step(ack, configure):
+                pass
+            @my_step.save
+            def save_my_step(ack, step, update):
+                pass
+            @my_step.execute
+            def execute_my_step(step, complete, fail):
+                pass
+            app.step(my_step)
+
+        For further information about WorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            callback_id: The callback_id for the workflow
+            app_name: The application name mainly for logging
+            base_logger: The base logger
+        """
+        self.callback_id = callback_id
+        self.app_name = app_name or __name__
+        self._base_logger = base_logger
+        self._edit = None
+        self._save = None
+        self._execute = None
+
+    def edit(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new edit listener with details.
+
+        You can use this method as decorator as well.
+
+            @my_step.edit
+            def edit_my_step(ack, configure):
+                pass
+
+        It's also possible to add additional listener matchers and/or middleware
+
+            @my_step.edit(matchers=[is_valid], middleware=[update_context])
+            def edit_my_step(ack, configure):
+                pass
+
+        For further information about WorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            *args: This method can behave as either decorator or a method
+            matchers: Listener matchers
+            middleware: Listener middleware
+            lazy: Lazy listeners
+        """
+
+        if _is_used_without_argument(args):
+            func = args[0]
+            self._edit = self._to_listener("edit", func, matchers, middleware)
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._edit = self._to_listener("edit", functions, matchers, middleware)
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def save(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new save listener with details.
+
+        You can use this method as decorator as well.
+
+            @my_step.save
+            def save_my_step(ack, step, update):
+                pass
+
+        It's also possible to add additional listener matchers and/or middleware
+
+            @my_step.save(matchers=[is_valid], middleware=[update_context])
+            def save_my_step(ack, step, update):
+                pass
+
+        For further information about WorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            *args: This method can behave as either decorator or a method
+            matchers: Listener matchers
+            middleware: Listener middleware
+            lazy: Lazy listeners
+        """
+        if _is_used_without_argument(args):
+            func = args[0]
+            self._save = self._to_listener("save", func, matchers, middleware)
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._save = self._to_listener("save", functions, matchers, middleware)
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def execute(
+        self,
+        *args,
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+        lazy: Optional[List[Callable[..., None]]] = None,
+    ):
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Registers a new execute listener with details.
+
+        You can use this method as decorator as well.
+
+            @my_step.execute
+            def execute_my_step(step, complete, fail):
+                pass
+
+        It's also possible to add additional listener matchers and/or middleware
+
+            @my_step.save(matchers=[is_valid], middleware=[update_context])
+            def execute_my_step(step, complete, fail):
+                pass
+
+        For further information about WorkflowStep specific function arguments
+        such as `configure`, `update`, `complete`, and `fail`,
+        refer to `slack_bolt.workflows.step.utilities` API documents.
+
+        Args:
+            *args: This method can behave as either decorator or a method
+            matchers: Listener matchers
+            middleware: Listener middleware
+            lazy: Lazy listeners
+        """
+        if _is_used_without_argument(args):
+            func = args[0]
+            self._execute = self._to_listener("execute", func, matchers, middleware)
+            return func
+
+        def _inner(func):
+            functions = [func] + (lazy if lazy is not None else [])
+            self._execute = self._to_listener("execute", functions, matchers, middleware)
+
+            @wraps(func)
+            def _wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+
+            return _wrapper
+
+        return _inner
+
+    def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep":
+        """
+        Deprecated:
+            Steps from apps for legacy workflows are now deprecated.
+            Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+        Constructs a WorkflowStep object. This method may raise an exception
+        if the builder doesn't have enough configurations to build the object.
+
+        Returns:
+            WorkflowStep object
+        """
+        if self._edit is None:
+            raise BoltError("edit listener is not registered")
+        if self._save is None:
+            raise BoltError("save listener is not registered")
+        if self._execute is None:
+            raise BoltError("execute listener is not registered")
+
+        return WorkflowStep(
+            callback_id=self.callback_id,
+            edit=self._edit,
+            save=self._save,
+            execute=self._execute,
+            app_name=self.app_name,
+            base_logger=base_logger,
+        )
+
+    # ---------------------------------------
+
+    def _to_listener(
+        self,
+        name: str,
+        listener_or_functions: Union[Listener, Callable, List[Callable]],
+        matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+        middleware: Optional[Union[Callable, Middleware]] = None,
+    ) -> Listener:
+        return WorkflowStep.build_listener(
+            callback_id=self.callback_id,
+            app_name=self.app_name,
+            listener_or_functions=listener_or_functions,
+            name=name,
+            matchers=self.to_listener_matchers(self.app_name, matchers, self._base_logger),
+            middleware=self.to_listener_middleware(self.app_name, middleware, self._base_logger),
+            base_logger=self._base_logger,
+        )
+
+    @staticmethod
+    def to_listener_matchers(
+        app_name: str,
+        matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]],
+        base_logger: Optional[Logger] = None,
+    ) -> List[ListenerMatcher]:
+        _matchers = []
+        if matchers is not None:
+            for m in matchers:
+                if isinstance(m, ListenerMatcher):
+                    _matchers.append(m)
+                elif isinstance(m, Callable):
+                    _matchers.append(
+                        CustomListenerMatcher(
+                            app_name=app_name,
+                            func=m,
+                            base_logger=base_logger,
+                        )
+                    )
+                else:
+                    raise ValueError(f"Invalid matcher: {type(m)}")
+        return _matchers
+
+    @staticmethod
+    def to_listener_middleware(
+        app_name: str,
+        middleware: Optional[List[Union[Callable, Middleware]]],
+        base_logger: Optional[Logger] = None,
+    ) -> List[Middleware]:
+        _middleware = []
+        if middleware is not None:
+            for m in middleware:
+                if isinstance(m, Middleware):
+                    _middleware.append(m)
+                elif isinstance(m, Callable):
+                    _middleware.append(
+                        CustomMiddleware(
+                            app_name=app_name,
+                            func=m,
+                            base_logger=base_logger,
+                        )
+                    )
+                else:
+                    raise ValueError(f"Invalid middleware: {type(m)}")
+        return _middleware
+
+

Steps from apps +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

This builder is supposed to be used as decorator.

+
my_step = WorkflowStep.builder("my_step")
+@my_step.edit
+def edit_my_step(ack, configure):
+    pass
+@my_step.save
+def save_my_step(ack, step, update):
+    pass
+@my_step.execute
+def execute_my_step(step, complete, fail):
+    pass
+app.step(my_step)
+
+

For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
callback_id
+
The callback_id for the workflow
+
app_name
+
The application name mainly for logging
+
base_logger
+
The base logger
+
+

Class variables

+
+
var callback_id : str | Pattern
+
+

The type of the None singleton.

+
+
+

Static methods

+
+
+def to_listener_matchers(app_name: str,
matchers: List[ListenerMatcher | Callable[..., bool]] | None,
base_logger: logging.Logger | None = None) ‑> List[ListenerMatcher]
+
+
+
+ +Expand source code + +
@staticmethod
+def to_listener_matchers(
+    app_name: str,
+    matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]],
+    base_logger: Optional[Logger] = None,
+) -> List[ListenerMatcher]:
+    _matchers = []
+    if matchers is not None:
+        for m in matchers:
+            if isinstance(m, ListenerMatcher):
+                _matchers.append(m)
+            elif isinstance(m, Callable):
+                _matchers.append(
+                    CustomListenerMatcher(
+                        app_name=app_name,
+                        func=m,
+                        base_logger=base_logger,
+                    )
+                )
+            else:
+                raise ValueError(f"Invalid matcher: {type(m)}")
+    return _matchers
+
+
+
+
+def to_listener_middleware(app_name: str,
middleware: List[Callable | Middleware] | None,
base_logger: logging.Logger | None = None) ‑> List[Middleware]
+
+
+
+ +Expand source code + +
@staticmethod
+def to_listener_middleware(
+    app_name: str,
+    middleware: Optional[List[Union[Callable, Middleware]]],
+    base_logger: Optional[Logger] = None,
+) -> List[Middleware]:
+    _middleware = []
+    if middleware is not None:
+        for m in middleware:
+            if isinstance(m, Middleware):
+                _middleware.append(m)
+            elif isinstance(m, Callable):
+                _middleware.append(
+                    CustomMiddleware(
+                        app_name=app_name,
+                        func=m,
+                        base_logger=base_logger,
+                    )
+                )
+            else:
+                raise ValueError(f"Invalid middleware: {type(m)}")
+    return _middleware
+
+
+
+
+

Methods

+
+
+def build(self, base_logger: logging.Logger | None = None) ‑> WorkflowStep +
+
+
+ +Expand source code + +
def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep":
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Constructs a WorkflowStep object. This method may raise an exception
+    if the builder doesn't have enough configurations to build the object.
+
+    Returns:
+        WorkflowStep object
+    """
+    if self._edit is None:
+        raise BoltError("edit listener is not registered")
+    if self._save is None:
+        raise BoltError("save listener is not registered")
+    if self._execute is None:
+        raise BoltError("execute listener is not registered")
+
+    return WorkflowStep(
+        callback_id=self.callback_id,
+        edit=self._edit,
+        save=self._save,
+        execute=self._execute,
+        app_name=self.app_name,
+        base_logger=base_logger,
+    )
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Constructs a WorkflowStep object. This method may raise an exception +if the builder doesn't have enough configurations to build the object.

+

Returns

+

WorkflowStep object

+
+
+def edit(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def edit(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new edit listener with details.
+
+    You can use this method as decorator as well.
+
+        @my_step.edit
+        def edit_my_step(ack, configure):
+            pass
+
+    It's also possible to add additional listener matchers and/or middleware
+
+        @my_step.edit(matchers=[is_valid], middleware=[update_context])
+        def edit_my_step(ack, configure):
+            pass
+
+    For further information about WorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        *args: This method can behave as either decorator or a method
+        matchers: Listener matchers
+        middleware: Listener middleware
+        lazy: Lazy listeners
+    """
+
+    if _is_used_without_argument(args):
+        func = args[0]
+        self._edit = self._to_listener("edit", func, matchers, middleware)
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._edit = self._to_listener("edit", functions, matchers, middleware)
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new edit listener with details.

+

You can use this method as decorator as well.

+
@my_step.edit
+def edit_my_step(ack, configure):
+    pass
+
+

It's also possible to add additional listener matchers and/or middleware

+
@my_step.edit(matchers=[is_valid], middleware=[update_context])
+def edit_my_step(ack, configure):
+    pass
+
+

For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
*args
+
This method can behave as either decorator or a method
+
matchers
+
Listener matchers
+
middleware
+
Listener middleware
+
lazy
+
Lazy listeners
+
+
+
+def execute(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def execute(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new execute listener with details.
+
+    You can use this method as decorator as well.
+
+        @my_step.execute
+        def execute_my_step(step, complete, fail):
+            pass
+
+    It's also possible to add additional listener matchers and/or middleware
+
+        @my_step.save(matchers=[is_valid], middleware=[update_context])
+        def execute_my_step(step, complete, fail):
+            pass
+
+    For further information about WorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        *args: This method can behave as either decorator or a method
+        matchers: Listener matchers
+        middleware: Listener middleware
+        lazy: Lazy listeners
+    """
+    if _is_used_without_argument(args):
+        func = args[0]
+        self._execute = self._to_listener("execute", func, matchers, middleware)
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._execute = self._to_listener("execute", functions, matchers, middleware)
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new execute listener with details.

+

You can use this method as decorator as well.

+
@my_step.execute
+def execute_my_step(step, complete, fail):
+    pass
+
+

It's also possible to add additional listener matchers and/or middleware

+
@my_step.save(matchers=[is_valid], middleware=[update_context])
+def execute_my_step(step, complete, fail):
+    pass
+
+

For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
*args
+
This method can behave as either decorator or a method
+
matchers
+
Listener matchers
+
middleware
+
Listener middleware
+
lazy
+
Lazy listeners
+
+
+
+def save(self,
*args,
matchers: Callable[..., bool] | ListenerMatcher | None = None,
middleware: Callable | Middleware | None = None,
lazy: List[Callable[..., None]] | None = None)
+
+
+
+ +Expand source code + +
def save(
+    self,
+    *args,
+    matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None,
+    middleware: Optional[Union[Callable, Middleware]] = None,
+    lazy: Optional[List[Callable[..., None]]] = None,
+):
+    """
+    Deprecated:
+        Steps from apps for legacy workflows are now deprecated.
+        Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/
+
+    Registers a new save listener with details.
+
+    You can use this method as decorator as well.
+
+        @my_step.save
+        def save_my_step(ack, step, update):
+            pass
+
+    It's also possible to add additional listener matchers and/or middleware
+
+        @my_step.save(matchers=[is_valid], middleware=[update_context])
+        def save_my_step(ack, step, update):
+            pass
+
+    For further information about WorkflowStep specific function arguments
+    such as `configure`, `update`, `complete`, and `fail`,
+    refer to `slack_bolt.workflows.step.utilities` API documents.
+
+    Args:
+        *args: This method can behave as either decorator or a method
+        matchers: Listener matchers
+        middleware: Listener middleware
+        lazy: Lazy listeners
+    """
+    if _is_used_without_argument(args):
+        func = args[0]
+        self._save = self._to_listener("save", func, matchers, middleware)
+        return func
+
+    def _inner(func):
+        functions = [func] + (lazy if lazy is not None else [])
+        self._save = self._to_listener("save", functions, matchers, middleware)
+
+        @wraps(func)
+        def _wrapper(*args, **kwargs):
+            return func(*args, **kwargs)
+
+        return _wrapper
+
+    return _inner
+
+

Deprecated

+

Steps from apps for legacy workflows are now deprecated. +Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/

+

Registers a new save listener with details.

+

You can use this method as decorator as well.

+
@my_step.save
+def save_my_step(ack, step, update):
+    pass
+
+

It's also possible to add additional listener matchers and/or middleware

+
@my_step.save(matchers=[is_valid], middleware=[update_context])
+def save_my_step(ack, step, update):
+    pass
+
+

For further information about WorkflowStep specific function arguments +such as configure, update, complete, and fail, +refer to slack_bolt.workflows.step.utilities API documents.

+

Args

+
+
*args
+
This method can behave as either decorator or a method
+
matchers
+
Listener matchers
+
middleware
+
Listener middleware
+
lazy
+
Lazy listeners
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/step_middleware.html b/docs/reference/workflows/step/step_middleware.html new file mode 100644 index 000000000..2ac62dd93 --- /dev/null +++ b/docs/reference/workflows/step/step_middleware.html @@ -0,0 +1,149 @@ + + + + + + +slack_bolt.workflows.step.step_middleware API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.step_middleware

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class WorkflowStepMiddleware +(step: WorkflowStep) +
+
+
+ +Expand source code + +
class WorkflowStepMiddleware(Middleware):
+    """Base middleware for step from app specific ones"""
+
+    def __init__(self, step: WorkflowStep):
+        self.step = step
+
+    def process(
+        self,
+        *,
+        req: BoltRequest,
+        resp: BoltResponse,
+        # As this method is not supposed to be invoked by bolt-python users,
+        # the naming conflict with the built-in one affects
+        # only the internals of this method
+        next: Callable[[], BoltResponse],
+    ) -> Optional[BoltResponse]:
+
+        if self.step.edit.matches(req=req, resp=resp):
+            resp = self._run(self.step.edit, req, resp)
+            if resp is not None:
+                return resp
+        elif self.step.save.matches(req=req, resp=resp):
+            resp = self._run(self.step.save, req, resp)
+            if resp is not None:
+                return resp
+        elif self.step.execute.matches(req=req, resp=resp):
+            resp = self._run(self.step.execute, req, resp)
+            if resp is not None:
+                return resp
+
+        return next()
+
+    @staticmethod
+    def _run(
+        listener: Listener,
+        req: BoltRequest,
+        resp: BoltResponse,
+    ) -> Optional[BoltResponse]:
+        resp, next_was_not_called = listener.run_middleware(req=req, resp=resp)
+        if next_was_not_called:
+            return None
+
+        return req.context.listener_runner.run(
+            request=req,
+            response=resp,
+            listener_name=get_name_for_callable(listener.ack_function),
+            listener=listener,
+        )
+
+

Base middleware for step from app specific ones

+

Ancestors

+ +

Inherited members

+ +
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/async_complete.html b/docs/reference/workflows/step/utilities/async_complete.html new file mode 100644 index 000000000..8e95cc267 --- /dev/null +++ b/docs/reference/workflows/step/utilities/async_complete.html @@ -0,0 +1,140 @@ + + + + + + +slack_bolt.workflows.step.utilities.async_complete API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.async_complete

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncComplete +(*, client: slack_sdk.web.async_client.AsyncWebClient, body: dict) +
+
+
+ +Expand source code + +
class AsyncComplete:
+    """`complete()` utility to tell Slack the completion of a step from app execution.
+
+        async def execute(step, complete, fail):
+            inputs = step["inputs"]
+            # if everything was successful
+            outputs = {
+                "task_name": inputs["task_name"]["value"],
+                "task_description": inputs["task_description"]["value"],
+            }
+            await complete(outputs=outputs)
+
+        ws = AsyncWorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepCompleted API method.
+    Refer to https://api.slack.com/methods/workflows.stepCompleted for details.
+    """
+
+    def __init__(self, *, client: AsyncWebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    async def __call__(self, **kwargs) -> None:
+        await self.client.workflows_stepCompleted(
+            workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"],
+            **kwargs,
+        )
+
+

complete() utility to tell Slack the completion of a step from app execution.

+
async def execute(step, complete, fail):
+    inputs = step["inputs"]
+    # if everything was successful
+    outputs = {
+        "task_name": inputs["task_name"]["value"],
+        "task_description": inputs["task_description"]["value"],
+    }
+    await complete(outputs=outputs)
+
+ws = AsyncWorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepCompleted API method. +Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/async_configure.html b/docs/reference/workflows/step/utilities/async_configure.html new file mode 100644 index 000000000..10f236c47 --- /dev/null +++ b/docs/reference/workflows/step/utilities/async_configure.html @@ -0,0 +1,163 @@ + + + + + + +slack_bolt.workflows.step.utilities.async_configure API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.async_configure

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncConfigure +(*,
callback_id: str,
client: slack_sdk.web.async_client.AsyncWebClient,
body: dict)
+
+
+
+ +Expand source code + +
class AsyncConfigure:
+    """`configure()` utility to send the modal view in Workflow Builder.
+
+        async def edit(ack, step, configure):
+            await ack()
+
+            blocks = [
+                {
+                    "type": "input",
+                    "block_id": "task_name_input",
+                    "element": {
+                        "type": "plain_text_input",
+                        "action_id": "name",
+                        "placeholder": {"type": "plain_text", "text": "Add a task name"},
+                    },
+                    "label": {"type": "plain_text", "text": "Task name"},
+                },
+            ]
+            await configure(blocks=blocks)
+
+        ws = AsyncWorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
+    """
+
+    def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict):
+        self.callback_id = callback_id
+        self.client = client
+        self.body = body
+
+    async def __call__(
+        self,
+        *,
+        blocks: Optional[Sequence[Union[dict, Block]]] = None,
+    ) -> None:
+        await self.client.views_open(
+            trigger_id=self.body["trigger_id"],
+            view={
+                "type": "workflow_step",
+                "callback_id": self.callback_id,
+                "blocks": blocks,
+            },
+        )
+
+

configure() utility to send the modal view in Workflow Builder.

+
async def edit(ack, step, configure):
+    await ack()
+
+    blocks = [
+        {
+            "type": "input",
+            "block_id": "task_name_input",
+            "element": {
+                "type": "plain_text_input",
+                "action_id": "name",
+                "placeholder": {"type": "plain_text", "text": "Add a task name"},
+            },
+            "label": {"type": "plain_text", "text": "Task name"},
+        },
+    ]
+    await configure(blocks=blocks)
+
+ws = AsyncWorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/async_fail.html b/docs/reference/workflows/step/utilities/async_fail.html new file mode 100644 index 000000000..b27c36251 --- /dev/null +++ b/docs/reference/workflows/step/utilities/async_fail.html @@ -0,0 +1,138 @@ + + + + + + +slack_bolt.workflows.step.utilities.async_fail API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.async_fail

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncFail +(*, client: slack_sdk.web.async_client.AsyncWebClient, body: dict) +
+
+
+ +Expand source code + +
class AsyncFail:
+    """`fail()` utility to tell Slack the execution failure of a step from app.
+
+        async def execute(step, complete, fail):
+            inputs = step["inputs"]
+            # if something went wrong
+            error = {"message": "Just testing step failure!"}
+            await fail(error=error)
+
+        ws = AsyncWorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepFailed API method.
+    Refer to https://api.slack.com/methods/workflows.stepFailed for details.
+    """
+
+    def __init__(self, *, client: AsyncWebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    async def __call__(
+        self,
+        *,
+        error: dict,
+    ) -> None:
+        await self.client.workflows_stepFailed(
+            workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"],
+            error=error,
+        )
+
+

fail() utility to tell Slack the execution failure of a step from app.

+
async def execute(step, complete, fail):
+    inputs = step["inputs"]
+    # if something went wrong
+    error = {"message": "Just testing step failure!"}
+    await fail(error=error)
+
+ws = AsyncWorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepFailed API method. +Refer to https://api.slack.com/methods/workflows.stepFailed for details.

+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/async_update.html b/docs/reference/workflows/step/utilities/async_update.html new file mode 100644 index 000000000..bfb210fc3 --- /dev/null +++ b/docs/reference/workflows/step/utilities/async_update.html @@ -0,0 +1,172 @@ + + + + + + +slack_bolt.workflows.step.utilities.async_update API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.async_update

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class AsyncUpdate +(*, client: slack_sdk.web.async_client.AsyncWebClient, body: dict) +
+
+
+ +Expand source code + +
class AsyncUpdate:
+    """`update()` utility to tell Slack the processing results of a `save` listener.
+
+        async def save(ack, view, update):
+            await ack()
+
+            values = view["state"]["values"]
+            task_name = values["task_name_input"]["name"]
+            task_description = values["task_description_input"]["description"]
+
+            inputs = {
+                "task_name": {"value": task_name["value"]},
+                "task_description": {"value": task_description["value"]}
+            }
+            outputs = [
+                {
+                    "type": "text",
+                    "name": "task_name",
+                    "label": "Task name",
+                },
+                {
+                    "type": "text",
+                    "name": "task_description",
+                    "label": "Task description",
+                }
+            ]
+            await update(inputs=inputs, outputs=outputs)
+
+        ws = AsyncWorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepFailed API method.
+    Refer to https://api.slack.com/methods/workflows.updateStep for details.
+    """
+
+    def __init__(self, *, client: AsyncWebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    async def __call__(self, **kwargs) -> None:
+        await self.client.workflows_updateStep(
+            workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"],
+            **kwargs,
+        )
+
+

update() utility to tell Slack the processing results of a save listener.

+
async def save(ack, view, update):
+    await ack()
+
+    values = view["state"]["values"]
+    task_name = values["task_name_input"]["name"]
+    task_description = values["task_description_input"]["description"]
+
+    inputs = {
+        "task_name": {"value": task_name["value"]},
+        "task_description": {"value": task_description["value"]}
+    }
+    outputs = [
+        {
+            "type": "text",
+            "name": "task_name",
+            "label": "Task name",
+        },
+        {
+            "type": "text",
+            "name": "task_description",
+            "label": "Task description",
+        }
+    ]
+    await update(inputs=inputs, outputs=outputs)
+
+ws = AsyncWorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepFailed API method. +Refer to https://api.slack.com/methods/workflows.updateStep for details.

+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/complete.html b/docs/reference/workflows/step/utilities/complete.html new file mode 100644 index 000000000..f1cf11f56 --- /dev/null +++ b/docs/reference/workflows/step/utilities/complete.html @@ -0,0 +1,140 @@ + + + + + + +slack_bolt.workflows.step.utilities.complete API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.complete

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Complete +(*, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Complete:
+    """`complete()` utility to tell Slack the completion of a step from app execution.
+
+        def execute(step, complete, fail):
+            inputs = step["inputs"]
+            # if everything was successful
+            outputs = {
+                "task_name": inputs["task_name"]["value"],
+                "task_description": inputs["task_description"]["value"],
+            }
+            complete(outputs=outputs)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepCompleted API method.
+    Refer to https://api.slack.com/methods/workflows.stepCompleted for details.
+    """
+
+    def __init__(self, *, client: WebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    def __call__(self, **kwargs) -> None:
+        self.client.workflows_stepCompleted(
+            workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"],
+            **kwargs,
+        )
+
+

complete() utility to tell Slack the completion of a step from app execution.

+
def execute(step, complete, fail):
+    inputs = step["inputs"]
+    # if everything was successful
+    outputs = {
+        "task_name": inputs["task_name"]["value"],
+        "task_description": inputs["task_description"]["value"],
+    }
+    complete(outputs=outputs)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepCompleted API method. +Refer to https://api.slack.com/methods/workflows.stepCompleted for details.

+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/configure.html b/docs/reference/workflows/step/utilities/configure.html new file mode 100644 index 000000000..258bce312 --- /dev/null +++ b/docs/reference/workflows/step/utilities/configure.html @@ -0,0 +1,160 @@ + + + + + + +slack_bolt.workflows.step.utilities.configure API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.configure

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Configure +(*, callback_id: str, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Configure:
+    """`configure()` utility to send the modal view in Workflow Builder.
+
+        def edit(ack, step, configure):
+            ack()
+
+            blocks = [
+                {
+                    "type": "input",
+                    "block_id": "task_name_input",
+                    "element": {
+                        "type": "plain_text_input",
+                        "action_id": "name",
+                        "placeholder": {"type": "plain_text", "text": "Add a task name"},
+                    },
+                    "label": {"type": "plain_text", "text": "Task name"},
+                },
+            ]
+            configure(blocks=blocks)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.
+    """
+
+    def __init__(self, *, callback_id: str, client: WebClient, body: dict):
+        self.callback_id = callback_id
+        self.client = client
+        self.body = body
+
+    def __call__(self, *, blocks: Optional[Sequence[Union[dict, Block]]] = None, **kwargs) -> None:
+        self.client.views_open(
+            trigger_id=self.body["trigger_id"],
+            view={
+                "type": "workflow_step",
+                "callback_id": self.callback_id,
+                "blocks": blocks,
+                **kwargs,
+            },
+        )
+
+

configure() utility to send the modal view in Workflow Builder.

+
def edit(ack, step, configure):
+    ack()
+
+    blocks = [
+        {
+            "type": "input",
+            "block_id": "task_name_input",
+            "element": {
+                "type": "plain_text_input",
+                "action_id": "name",
+                "placeholder": {"type": "plain_text", "text": "Add a task name"},
+            },
+            "label": {"type": "plain_text", "text": "Task name"},
+        },
+    ]
+    configure(blocks=blocks)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details.

+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/fail.html b/docs/reference/workflows/step/utilities/fail.html new file mode 100644 index 000000000..00d0be83d --- /dev/null +++ b/docs/reference/workflows/step/utilities/fail.html @@ -0,0 +1,138 @@ + + + + + + +slack_bolt.workflows.step.utilities.fail API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.fail

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Fail +(*, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Fail:
+    """`fail()` utility to tell Slack the execution failure of a step from app.
+
+        def execute(step, complete, fail):
+            inputs = step["inputs"]
+            # if something went wrong
+            error = {"message": "Just testing step failure!"}
+            fail(error=error)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepFailed API method.
+    Refer to https://api.slack.com/methods/workflows.stepFailed for details.
+    """
+
+    def __init__(self, *, client: WebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    def __call__(
+        self,
+        *,
+        error: dict,
+    ) -> None:
+        self.client.workflows_stepFailed(
+            workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"],
+            error=error,
+        )
+
+

fail() utility to tell Slack the execution failure of a step from app.

+
def execute(step, complete, fail):
+    inputs = step["inputs"]
+    # if something went wrong
+    error = {"message": "Just testing step failure!"}
+    fail(error=error)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepFailed API method. +Refer to https://api.slack.com/methods/workflows.stepFailed for details.

+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/index.html b/docs/reference/workflows/step/utilities/index.html new file mode 100644 index 000000000..54261ea96 --- /dev/null +++ b/docs/reference/workflows/step/utilities/index.html @@ -0,0 +1,133 @@ + + + + + + +slack_bolt.workflows.step.utilities API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities

+
+
+

Utilities specific to steps from apps.

+

In steps from apps listeners, you can use a few specific listener/middleware arguments.

+

edit listener

+ +

save listener

+ +

execute listener

+ +

For asyncio-based apps, refer to the corresponding async prefixed ones.

+
+
+

Sub-modules

+
+
slack_bolt.workflows.step.utilities.async_complete
+
+
+
+
slack_bolt.workflows.step.utilities.async_configure
+
+
+
+
slack_bolt.workflows.step.utilities.async_fail
+
+
+
+
slack_bolt.workflows.step.utilities.async_update
+
+
+
+
slack_bolt.workflows.step.utilities.complete
+
+
+
+
slack_bolt.workflows.step.utilities.configure
+
+
+
+
slack_bolt.workflows.step.utilities.fail
+
+
+
+
slack_bolt.workflows.step.utilities.update
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + + diff --git a/docs/reference/workflows/step/utilities/update.html b/docs/reference/workflows/step/utilities/update.html new file mode 100644 index 000000000..9899448f9 --- /dev/null +++ b/docs/reference/workflows/step/utilities/update.html @@ -0,0 +1,172 @@ + + + + + + +slack_bolt.workflows.step.utilities.update API documentation + + + + + + + + + + + +
+
+
+

Module slack_bolt.workflows.step.utilities.update

+
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Update +(*, client: slack_sdk.web.client.WebClient, body: dict) +
+
+
+ +Expand source code + +
class Update:
+    """`update()` utility to tell Slack the processing results of a `save` listener.
+
+        def save(ack, view, update):
+            ack()
+
+            values = view["state"]["values"]
+            task_name = values["task_name_input"]["name"]
+            task_description = values["task_description_input"]["description"]
+
+            inputs = {
+                "task_name": {"value": task_name["value"]},
+                "task_description": {"value": task_description["value"]}
+            }
+            outputs = [
+                {
+                    "type": "text",
+                    "name": "task_name",
+                    "label": "Task name",
+                },
+                {
+                    "type": "text",
+                    "name": "task_description",
+                    "label": "Task description",
+                }
+            ]
+            update(inputs=inputs, outputs=outputs)
+
+        ws = WorkflowStep(
+            callback_id="add_task",
+            edit=edit,
+            save=save,
+            execute=execute,
+        )
+        app.step(ws)
+
+    This utility is a thin wrapper of workflows.stepFailed API method.
+    Refer to https://api.slack.com/methods/workflows.updateStep for details.
+    """
+
+    def __init__(self, *, client: WebClient, body: dict):
+        self.client = client
+        self.body = body
+
+    def __call__(self, **kwargs) -> None:
+        self.client.workflows_updateStep(
+            workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"],
+            **kwargs,
+        )
+
+

update() utility to tell Slack the processing results of a save listener.

+
def save(ack, view, update):
+    ack()
+
+    values = view["state"]["values"]
+    task_name = values["task_name_input"]["name"]
+    task_description = values["task_description_input"]["description"]
+
+    inputs = {
+        "task_name": {"value": task_name["value"]},
+        "task_description": {"value": task_description["value"]}
+    }
+    outputs = [
+        {
+            "type": "text",
+            "name": "task_name",
+            "label": "Task name",
+        },
+        {
+            "type": "text",
+            "name": "task_description",
+            "label": "Task description",
+        }
+    ]
+    update(inputs=inputs, outputs=outputs)
+
+ws = WorkflowStep(
+    callback_id="add_task",
+    edit=edit,
+    save=save,
+    execute=execute,
+)
+app.step(ws)
+
+

This utility is a thin wrapper of workflows.stepFailed API method. +Refer to https://api.slack.com/methods/workflows.updateStep for details.

+
+
+
+
+ +
+ + + diff --git a/examples/aiohttp_devtools/async_app.py b/examples/aiohttp_devtools/async_app.py new file mode 100644 index 000000000..2b4c289e2 --- /dev/null +++ b/examples/aiohttp_devtools/async_app.py @@ -0,0 +1,30 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp + +app = AsyncApp() + + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + + +@app.command("/hello-bolt-python") +# or app.command(re.compile(r"/hello-.+"))(test_command) +async def command(ack, body): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +def app_factory(): + return app.web_app() + + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# adev runserver --port 3000 --app-factory app_factory async_app.py diff --git a/examples/aiohttp_devtools/requirements.txt b/examples/aiohttp_devtools/requirements.txt new file mode 100644 index 000000000..2e4b095ca --- /dev/null +++ b/examples/aiohttp_devtools/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=3,<4 +aiohttp-devtools>=0.13,<0.14 \ No newline at end of file diff --git a/samples/app.py b/examples/app.py similarity index 51% rename from samples/app.py rename to examples/app.py index 0cddb3829..6e5a10a0f 100644 --- a/samples/app.py +++ b/examples/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -15,17 +8,29 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() +@app.command("/hello-bolt-python") +def hello_command(ack, body): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") +@app.error +def global_error_handler(error, body, logger): + logger.exception(error) + logger.info(body) + + if __name__ == "__main__": app.start(3000) diff --git a/examples/app_authorize.py b/examples/app_authorize.py new file mode 100644 index 000000000..843ecd6f6 --- /dev/null +++ b/examples/app_authorize.py @@ -0,0 +1,42 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os +from slack_bolt import App +from slack_bolt.authorization import AuthorizeResult +from slack_sdk import WebClient + + +def authorize(enterprise_id, team_id, user_id, client: WebClient, logger): + logger.info(f"{enterprise_id},{team_id},{user_id}") + # You can implement your own logic here + token = os.environ["MY_TOKEN"] + return AuthorizeResult.from_auth_test_response( + auth_test_response=client.auth_test(token=token), + bot_token=token, + ) + + +app = App(signing_secret=os.environ["SLACK_SIGNING_SECRET"], authorize=authorize) + + +@app.command("/hello-bolt-python") +def hello_command(ack, body): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("What's up?") + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export MY_TOKEN=xoxb-*** +# python app_authorize.py diff --git a/examples/asgi/app.py b/examples/asgi/app.py new file mode 100644 index 000000000..27b5e7d09 --- /dev/null +++ b/examples/asgi/app.py @@ -0,0 +1,19 @@ +from slack_bolt import App +from slack_bolt.adapter.asgi import SlackRequestHandler + +app = App() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +api = SlackRequestHandler(app) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn app:api --reload --port 3000 --log-level debug +# ngrok http 3000 diff --git a/examples/asgi/async_app.py b/examples/asgi/async_app.py new file mode 100644 index 000000000..4b4eb90a8 --- /dev/null +++ b/examples/asgi/async_app.py @@ -0,0 +1,19 @@ +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.asgi.async_handler import AsyncSlackRequestHandler + +app = AsyncApp() + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = AsyncSlackRequestHandler(app) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn async_app:api --reload --port 3000 --log-level debug +# ngrok http 3000 diff --git a/examples/asgi/async_oauth_app.py b/examples/asgi/async_oauth_app.py new file mode 100644 index 000000000..307b91ed1 --- /dev/null +++ b/examples/asgi/async_oauth_app.py @@ -0,0 +1,23 @@ +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.asgi.async_handler import AsyncSlackRequestHandler + +app = AsyncApp() + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = AsyncSlackRequestHandler(app) + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write + +# uvicorn async_oauth_app:api --reload --port 3000 --log-level debug diff --git a/examples/asgi/oauth_app.py b/examples/asgi/oauth_app.py new file mode 100644 index 000000000..c843ad4a0 --- /dev/null +++ b/examples/asgi/oauth_app.py @@ -0,0 +1,23 @@ +from slack_bolt import App +from slack_bolt.adapter.asgi import SlackRequestHandler + +app = App() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +api = SlackRequestHandler(app) + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write + +# uvicorn oauth_app:api --reload --port 3000 --log-level debug diff --git a/examples/asgi/requirements.txt b/examples/asgi/requirements.txt new file mode 100644 index 000000000..10c1fe01f --- /dev/null +++ b/examples/asgi/requirements.txt @@ -0,0 +1,2 @@ +uvicorn<1 +aiohttp>=3,<4 diff --git a/examples/assistants/app.py b/examples/assistants/app.py new file mode 100644 index 000000000..1c3a7a28a --- /dev/null +++ b/examples/assistants/app.py @@ -0,0 +1,95 @@ +import logging +import os +import time + +from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App, Assistant, SetStatus, SetTitle, SetSuggestedPrompts, Say +from slack_bolt.adapter.socket_mode import SocketModeHandler + +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + + +assistant = Assistant() +# You can use your own thread_context_store if you want +# from slack_bolt import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts): + say(":wave: Hi, how can I help you today?") + set_suggested_prompts( + prompts=[ + "What does SLACK stand for?", + "When Slack was released?", + ] + ) + + +@assistant.user_message(matchers=[lambda payload: "help page" in payload["text"]]) +def find_help_pages( + payload: dict, + logger: logging.Logger, + set_title: SetTitle, + set_status: SetStatus, + say: Say, +): + try: + set_title(payload["text"]) + set_status("Searching help pages...") + time.sleep(0.5) + say("Please check this help page: https://www.example.com/help-page-123") + 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})") + + +@assistant.user_message +def answer_other_inquiries( + payload: dict, + logger: logging.Logger, + set_title: SetTitle, + set_status: SetStatus, + say: Say, + get_thread_context: GetThreadContext, +): + try: + set_title(payload["text"]) + set_status("Typing...") + time.sleep(0.3) + set_status("Still typing...") + time.sleep(0.3) + thread_context = get_thread_context() + if thread_context is not None: + channel = thread_context.channel_id + say(f"Ah, you're referring to <#{channel}>! Do you need help with the channel?") + else: + say("Here you are! blah-blah-blah...") + 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})") + + +app.use(assistant) + + +@app.event("message") +def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +def handle_non_assistant_thread_messages(say: Say): + say(":wave: I can help you out within our 1:1 DM!") + + +if __name__ == "__main__": + SocketModeHandler(app, app_token=os.environ["SLACK_APP_TOKEN"]).start() + +# pip install slack_bolt +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python app.py diff --git a/examples/assistants/async_app.py b/examples/assistants/async_app.py new file mode 100644 index 000000000..be7475a6f --- /dev/null +++ b/examples/assistants/async_app.py @@ -0,0 +1,97 @@ +import logging +import os +import asyncio + +from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp, AsyncAssistant, AsyncSetTitle, AsyncSetStatus, AsyncSetSuggestedPrompts, AsyncSay +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + + +assistant = AsyncAssistant() + + +@assistant.thread_started +async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPrompts): + await say(":wave: Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[ + "What does SLACK stand for?", + "When Slack was released?", + ] + ) + + +@assistant.user_message(matchers=[lambda body: "help page" in body["event"]["text"]]) +async def find_help_pages( + payload: dict, + logger: logging.Logger, + set_title: AsyncSetTitle, + set_status: AsyncSetStatus, + say: AsyncSay, +): + try: + await set_title(payload["text"]) + await set_status("Searching help pages...") + await asyncio.sleep(0.5) + await say("Please check this help page: https://www.example.com/help-page-123") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +@assistant.user_message +async def answer_other_inquiries( + payload: dict, + logger: logging.Logger, + set_title: AsyncSetTitle, + set_status: AsyncSetStatus, + say: AsyncSay, + get_thread_context: AsyncGetThreadContext, +): + try: + await set_title(payload["text"]) + await set_status("Typing...") + await asyncio.sleep(0.3) + await set_status("Still typing...") + await asyncio.sleep(0.3) + thread_context = await get_thread_context() + if thread_context is not None: + channel = thread_context.channel_id + await say(f"Ah, you're referring to <#{channel}>! Do you need help with the channel?") + else: + await say("Here you are! blah-blah-blah...") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +async def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +async def handle_non_assistant_thread_messages(say: AsyncSay): + await say(":wave: I can help you out within our 1:1 DM!") + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + asyncio.run(main()) + +# pip install slack_bolt aiohttp +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python async_app.py diff --git a/examples/assistants/async_interaction_app.py b/examples/assistants/async_interaction_app.py new file mode 100644 index 000000000..b9e8de3bc --- /dev/null +++ b/examples/assistants/async_interaction_app.py @@ -0,0 +1,320 @@ +# flake8: noqa F811 +import asyncio +import logging +import os +import random +import json + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp, AsyncAssistant, AsyncSetStatus, AsyncSay, AsyncAck +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from slack_sdk.web.async_client import AsyncWebClient + +app = AsyncApp( + token=os.environ["SLACK_BOT_TOKEN"], + # This must be set to handle bot message events + ignoring_self_assistant_message_events_enabled=False, +) + + +assistant = AsyncAssistant() +# You can use your own thread_context_store if you want +# from slack_bolt import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +async def start_thread(say: AsyncSay): + await 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": "1", + }, + ], + }, + ], + ) + + +@app.action("assistant-generate-random-numbers") +async def configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, body: dict): + await ack() + await 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"}, + "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") +async def receive_configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, payload: dict): + await ack() + num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] + thread = json.loads(payload["private_metadata"]) + await 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 +async def respond_to_bot_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay, payload: dict): + try: + if payload.get("metadata", {}).get("event_type") == "assistant-generate-random-numbers": + await set_status("is generating an array of random numbers...") + await asyncio.sleep(1) + nums: Set[str] = set() + num = payload["metadata"]["event_payload"]["num"] + while len(nums) < num: + nums.add(str(random.randint(1, 100))) + await say(f"Here you are: {', '.join(nums)}") + else: + # nothing to do for this bot message + # If you want to add more patterns here, be careful not to cause infinite loop messaging + pass + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + + +@assistant.user_message +async def respond_to_user_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay): + try: + await set_status("is typing...") + await say("Sorry, I couldn't understand your comment. Could you say it in a different way?") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +async def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +async def handle_non_assistant_thread_messages(say: AsyncSay): + await say(":wave: I can help you out within our 1:1 DM!") + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + asyncio.run(main()) + +# pip install slack_bolt aiohttp +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python async_interaction_app.py +import asyncio +import json +import logging +import os +from typing import Set +import random + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp, AsyncAssistant, AsyncSetStatus, AsyncSay, AsyncAck +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from slack_sdk.web.async_client import AsyncWebClient + +app = AsyncApp( + token=os.environ["SLACK_BOT_TOKEN"], + # This must be set to handle bot message events + ignoring_self_assistant_message_events_enabled=False, +) + + +assistant = AsyncAssistant() +# You can use your own thread_context_store if you want +# from slack_bolt import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +async def start_thread(say: AsyncSay): + await 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": "1", + }, + ], + }, + ], + ) + + +@app.action("assistant-generate-random-numbers") +async def configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, body: dict): + await ack() + await 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"}, + "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") +async def receive_configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, payload: dict): + await ack() + num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] + thread = json.loads(payload["private_metadata"]) + await 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 +async def respond_to_bot_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay, payload: dict): + try: + if payload.get("metadata", {}).get("event_type") == "assistant-generate-random-numbers": + await set_status("is generating an array of random numbers...") + await asyncio.sleep(1) + nums: Set[str] = set() + num = payload["metadata"]["event_payload"]["num"] + while len(nums) < num: + nums.add(str(random.randint(1, 100))) + await say(f"Here you are: {', '.join(nums)}") + else: + # nothing to do for this bot message + # If you want to add more patterns here, be careful not to cause infinite loop messaging + pass + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + + +@assistant.user_message +async def respond_to_user_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay): + try: + await set_status("is typing...") + await say("Sorry, I couldn't understand your comment. Could you say it in a different way?") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +async def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +async def handle_non_assistant_thread_messages(say: AsyncSay): + await say(":wave: I can help you out within our 1:1 DM!") + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + asyncio.run(main()) + +# pip install slack_bolt aiohttp +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python async_interaction_app.py diff --git a/examples/assistants/interaction_app.py b/examples/assistants/interaction_app.py new file mode 100644 index 000000000..101035739 --- /dev/null +++ b/examples/assistants/interaction_app.py @@ -0,0 +1,155 @@ +import json +import logging +import os +from typing import Set +import random +import time + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App, Assistant, SetStatus, Say, Ack +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient + +app = App( + token=os.environ["SLACK_BOT_TOKEN"], + # This must be set to handle bot message events + ignoring_self_assistant_message_events_enabled=False, +) + + +assistant = Assistant() +# You can use your own thread_context_store if you want +# from slack_bolt import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +def start_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": "1", + }, + ], + }, + ], + ) + + +@app.action("assistant-generate-random-numbers") +def configure_assistant_summarize_channel(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"}, + "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_configure_assistant_summarize_channel(ack: Ack, client: WebClient, payload: dict): + ack() + num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] + thread = json.loads(payload["private_metadata"]) + 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": + 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: + # nothing to do for this bot message + # If you want to add more patterns here, be careful not to cause infinite loop messaging + 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("Sorry, I couldn't understand your comment. Could you say it in a different way?") + 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})") + + +app.use(assistant) + + +@app.event("message") +def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +def handle_non_assistant_thread_messages(say: Say): + say(":wave: I can help you out within our 1:1 DM!") + + +if __name__ == "__main__": + SocketModeHandler(app, app_token=os.environ["SLACK_APP_TOKEN"]).start() + +# pip install slack_bolt +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python interaction_app.py diff --git a/samples/async_app.py b/examples/async_app.py similarity index 55% rename from samples/async_app.py rename to examples/async_app.py index 67a726df3..a699dbb29 100644 --- a/samples/async_app.py +++ b/examples/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -15,21 +8,22 @@ @app.middleware # or app.use(log_request) -async def log_request(logger, payload, next): - logger.debug(payload) +async def log_request(logger, body, next): + logger.debug(body) return await next() @app.event("app_mention") -async def event_test(payload, say, logger): - logger.info(payload) +async def event_test(body, say, logger): + logger.info(body) await say("What's up?") @app.command("/hello-bolt-python") # or app.command(re.compile(r"/hello-.+"))(test_command) -async def command(ack): - await ack("Thanks!") +async def command(ack, body): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") if __name__ == "__main__": @@ -38,4 +32,4 @@ async def command(ack): # pip install slack_bolt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** -# python app.py +# python async_app.py diff --git a/examples/async_app_authorize.py b/examples/async_app_authorize.py new file mode 100644 index 000000000..218c3ef8a --- /dev/null +++ b/examples/async_app_authorize.py @@ -0,0 +1,43 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.async_app import AsyncApp + + +async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient, logger): + logger.info(f"{enterprise_id},{team_id},{user_id}") + # You can implement your own logic here + token = os.environ["MY_TOKEN"] + return AuthorizeResult.from_auth_test_response( + auth_test_response=await client.auth_test(token=token), + bot_token=token, + ) + + +app = AsyncApp(signing_secret=os.environ["SLACK_SIGNING_SECRET"], authorize=authorize) + + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + + +@app.command("/hello-bolt-python") +# or app.command(re.compile(r"/hello-.+"))(test_command) +async def command(ack, body): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export MY_TOKEN=xoxb-*** +# python app.py diff --git a/samples/aws_chalice/.chalice/config.json.oauth b/examples/aws_chalice/.chalice/config.json.oauth similarity index 100% rename from samples/aws_chalice/.chalice/config.json.oauth rename to examples/aws_chalice/.chalice/config.json.oauth diff --git a/samples/aws_chalice/.chalice/config.json.simple b/examples/aws_chalice/.chalice/config.json.simple similarity index 65% rename from samples/aws_chalice/.chalice/config.json.simple rename to examples/aws_chalice/.chalice/config.json.simple index 518945a25..97fe6749b 100644 --- a/samples/aws_chalice/.chalice/config.json.simple +++ b/examples/aws_chalice/.chalice/config.json.simple @@ -7,7 +7,9 @@ "environment_variables": { "SLACK_BOT_TOKEN": "xoxb-xxx", "SLACK_SIGNING_SECRET": "xxx" - } + }, + "manage_iam_role": false, + "iam_role_arn": "arn:aws:iam::1111111111111:role/bolt_python_lambda_invocation" } } } \ No newline at end of file diff --git a/samples/aws_chalice/.gitignore b/examples/aws_chalice/.gitignore similarity index 100% rename from samples/aws_chalice/.gitignore rename to examples/aws_chalice/.gitignore diff --git a/samples/aws_chalice/app.py b/examples/aws_chalice/app.py similarity index 82% rename from samples/aws_chalice/app.py rename to examples/aws_chalice/app.py index 3d63a9ce4..6c41942f3 100644 --- a/samples/aws_chalice/app.py +++ b/examples/aws_chalice/app.py @@ -1,4 +1,5 @@ import logging +import time from chalice import Chalice, Response @@ -6,22 +7,25 @@ from slack_bolt.adapter.aws_lambda.chalice_handler import ChaliceSlackRequestHandler # process_before_response must be True when running on FaaS -bolt_app = App( - process_before_response=True, - authorization_test_enabled=False, -) +bolt_app = App(process_before_response=True) @bolt_app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up? I'm a Chalice app :wave:") -@bolt_app.command("/hello-bolt-python-chalice") def respond_to_slack_within_3_seconds(ack): - ack("Thanks!") + ack("Accepted!") + + +def say_it(say): + time.sleep(5) + say("Done!") + +bolt_app.command("/hello-bolt-python-chalice")(ack=respond_to_slack_within_3_seconds, lazy=[say_it]) ChaliceSlackRequestHandler.clear_all_log_handlers() logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG) diff --git a/samples/aws_chalice/deploy.sh b/examples/aws_chalice/deploy.sh similarity index 100% rename from samples/aws_chalice/deploy.sh rename to examples/aws_chalice/deploy.sh diff --git a/samples/aws_chalice/oauth_app.py b/examples/aws_chalice/oauth_app.py similarity index 96% rename from samples/aws_chalice/oauth_app.py rename to examples/aws_chalice/oauth_app.py index 56c943a13..075c7658d 100644 --- a/samples/aws_chalice/oauth_app.py +++ b/examples/aws_chalice/oauth_app.py @@ -18,8 +18,8 @@ @bolt_app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up? I'm a Chalice app :wave:") diff --git a/examples/aws_chalice/requirements.txt b/examples/aws_chalice/requirements.txt new file mode 100644 index 000000000..bb964f6ea --- /dev/null +++ b/examples/aws_chalice/requirements.txt @@ -0,0 +1 @@ +slack_sdk \ No newline at end of file diff --git a/samples/aws_chalice/simple_app.py b/examples/aws_chalice/simple_app.py similarity index 90% rename from samples/aws_chalice/simple_app.py rename to examples/aws_chalice/simple_app.py index 3d63a9ce4..a4423e5fb 100644 --- a/samples/aws_chalice/simple_app.py +++ b/examples/aws_chalice/simple_app.py @@ -6,15 +6,12 @@ from slack_bolt.adapter.aws_lambda.chalice_handler import ChaliceSlackRequestHandler # process_before_response must be True when running on FaaS -bolt_app = App( - process_before_response=True, - authorization_test_enabled=False, -) +bolt_app = App(process_before_response=True) @bolt_app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up? I'm a Chalice app :wave:") diff --git a/samples/aws_lambda/.env.oauth_sample b/examples/aws_lambda/.env.oauth_sample similarity index 82% rename from samples/aws_lambda/.env.oauth_sample rename to examples/aws_lambda/.env.oauth_sample index 3de93cf4b..e08635e3d 100644 --- a/samples/aws_lambda/.env.oauth_sample +++ b/examples/aws_lambda/.env.oauth_sample @@ -4,4 +4,3 @@ export SLACK_CLIENT_SECRET= export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write export SLACK_INSTALLATION_S3_BUCKET_NAME= export SLACK_STATE_S3_BUCKET_NAME= -export SLACK_LAMBDA_PATH=/default/bolt_py_function diff --git a/samples/aws_lambda/.env.sample b/examples/aws_lambda/.env.sample similarity index 100% rename from samples/aws_lambda/.env.sample rename to examples/aws_lambda/.env.sample diff --git a/examples/aws_lambda/.gitignore b/examples/aws_lambda/.gitignore new file mode 100644 index 000000000..dac781e7f --- /dev/null +++ b/examples/aws_lambda/.gitignore @@ -0,0 +1,4 @@ +slack-bolt/ +slack_bolt/ +vendor/ +.env diff --git a/examples/aws_lambda/README.md b/examples/aws_lambda/README.md new file mode 100644 index 000000000..2fc34b7cf --- /dev/null +++ b/examples/aws_lambda/README.md @@ -0,0 +1,200 @@ +# AWS Lambda Bolt Python Examples + +This directory contains two example apps. Both respond to the Slash Command +`/hello-bolt-python-lambda` and both respond to app at-mentions. + +The "Lazy Lambda Listener" example is the simpler application and it leverages +AWS Lambda and AWS Lambda Function URL to execute the Bolt app logic in Lambda and +expose the application HTTP routes to the internet via Lambda URL. The "OAuth +Lambda Listener" example additionally includes OAuth flow handling routes and uses +AWS S3 to store workspace installation credentials and OAuth flow state +variables, enabling your app to be installed by anyone. + +Instructions on how to set up and deploy each example are provided below. + +## Lazy Lambda Listener Example Bolt App + +1. You need an AWS account and your AWS credentials set up on your machine. +2. Make sure you have an AWS IAM Role defined with the needed permissions for + your Lambda function powering your Slack app: + - Head to the AWS IAM section of AWS Console + - Click Roles from the menu + - Click the Create Role button + - Under "Select type of trusted entity", choose "AWS service" + - Under "Choose a use case", select "Common use cases: Lambda" + - Click "Next: Permissions" + - Under "Attach permission policies", enter "lambda" in the Filter input + - Check the "AWSLambdaBasicExecutionRole", "AWSLambdaExecute" and "AWSLambdaRole" policies + - Click "Next: tags" + - Click "Next: review" + - Enter `bolt_python_lambda_invocation` as the Role name. You can change this + if you want, but then make sure to update the role name in + `lazy_aws_lambda_config.yaml` + - Optionally enter a description for the role, such as "Bolt Python basic + role" +3. Ensure you have created an app on api.slack.com/apps as per the + [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/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/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 + region that you deployed your app to. You should see a `bolt_py_function` + Lambda there. +8. While your Lambda exists, it is not accessible to the internet, so Slack + cannot send events happening in your Slack workspace to your Lambda. Let's + fix that by adding an AWS Lambda Function URL to your Lambda so that your + Lambda can accept HTTP requests: + - Click on your `bolt_py_function` Lambda + - In the Function Overview click "Configuration" + - On the left side, click "Function URL" + - Click "Create function URL" + - Choose auth type "NONE" + - Click "Save" +9. Congrats! Your Slack app is now accessible to the public. On the right side of + your `bolt_py_function` Function Overview you should see your Lambda Function URL. +10. Copy this URL to your clipboard. +11. We will now inform Slack that this example app can accept Slash Commands. + - Back on api.slack.com/apps, select your app and choose Slash Commands from the left menu. + - Click Create New Command + - By default, the `lazy_aws_lambda.py` function has logic for a + `/hello-bolt-python-lambda` command. Enter `/hello-bolt-python-lambda` as + the Command. + - Under Request URL, paste in the previously-copied Lambda Function URL. + - Click Save +12. Test it out! Back in your Slack workspace, try typing + `/hello-bolt-python-lambda hello`. +13. If you have issues, here are some debugging options: + - Check the Monitor tab under your Lambda. Did the Lambda get invoked? Did it + respond with an error? Investigate the graphs to see how your Lambda is + behaving. + - From this same Monitor tab, you can also click "View Logs in CloudWatch" to + see the execution logs for your Lambda. This can be helpful to see what + errors are being raised. + +## OAuth Lambda Listener Example Bolt App + +### Setup your AWS Account + Credentials +You need an AWS account and your AWS credentials set up on your machine. + +Once you’ve done that you should have access to AWS Console, which is what we’ll use for the rest of this tutorial. + +### Create S3 Buckets to store Installations and State + +1. Start by creating two S3 buckets: + 1. One to store installation credentials for each Slack workspace that installs your app. + 2. One to store state variables during the OAuth flow. +2. Head over to **Amazon S3** in the AWS Console +3. Give your bucket a name, region, and set access controls. If you’re doing this for the first time, it’s easiest to keep the defaults and edit them later as necessary. We'll be using the names: + 1. slack-installations-s3 + 2. slack-state-store-s3 +4. After your buckets are created, in each bucket’s page head over to “Properties” and save the Amazon Resource Name (ARN). It should look something like `arn:aws:s3:::slack-installations-s3`. + +### Create a Policy to Enable Actions on S3 Buckets +Now let's create a policy that will allow the holder of the policy to take actions in your S3 buckets. + +1. Head over to Identity and Access Management (IAM) in the AWS Console via Search Bar +2. Head to **Access Management > Policies** and select “Create Policy” +3. Click the JSON tab and copy this in: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:Get*", + "s3:Put*", + "s3:Delete*", + "s3-object-lambda:*" + ], + "Resource": [ + "/*", // don't forget the `/*` + "/*" + ] + } + ] +} +``` +4. Edit “Resource” to include the ARNs of the two buckets you created in the earlier step. These need to exactly match the ARNS you copied earlier and end with a `/*` +5. Hit "Next:Tags" and "Next:Review" +6. Review policy + 1. Name your policy something memorable enough that you won’t have forgotten it 5 minutes from now when we’ll need to look it up from a list. (e.g. AmazonS3-FullAccess-SlackBuckets) + 2. Review the summary, and hit "Create Policy". Once the policy is created you should be redirected to the Policies page and see your new policy show up as Customer managed policy. + +### Setup an AWS IAM Role with Policies for Executing Your Lambda +Let’s create a user role that will use the custom policy we created as well as other policies to let us execute our lambda, write output logs to CloudWatch. + +1. Head to the **Identity and Access Management (IAM)** section of AWS Console +2. Select **Access Management > Roles** from the menu +3. Click "Create Role" +4. Step 1 - Select trusted entity + 1. Under "Select type of trusted entity", choose "AWS service" + 2. Under "Choose a use case", select "Common use cases: Lambda" + 3. Click "Next: Permissions" +5. Step 2 - Add permissions + 1. Add the following policies to the role we’re creating that will allow the user with the role permission to execute Lambda, make changes to their S3 Buckets, log output to CloudWatch + 1. `AWSLambdaExecute` + 2. `AWSLambdaBasicExecutionRole` + 3. `AWSLambdaRole` + 4. `` +6. Step 3 - Name, review, create + 1. Enter `bolt_python_s3_storage` as your role name. To use a different name, make sure to update the role name in `aws_lambda_oauth_config.yaml` + 2. Optionally enter a description for the role, such as "Bolt Python with S3 access role” + 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/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 + +```bash +SLACK_SIGNING_SECRET= # Signing Secret from Basic Information page +SLACK_CLIENT_ID= # Client ID from Basic Information page +SLACK_CLIENT_SECRET # Client Secret from Basic Information page +SLACK_SCOPES= "app_mentions:read,chat:write" +SLACK_INSTALLATION_S3_BUCKET_NAME: # The name of installations bucket +SLACK_STATE_S3_BUCKET_NAME: # The name of the state store bucket +export +``` +6. Let's deploy the Lambda! Run `./deploy_oauth.sh`. By default it deploys to the us-east-1 region in AWS - you can customize this in `aws_lambda_oauth_config.yaml`. +7. Load up AWS Lambda inside the AWS Console - make sure you are in the correct region that you deployed your app to. You should see a `bolt_py_oauth_function` Lambda there. + +### Set up AWS Lambda Function URL +Your Lambda exists, but it is not accessible to the internet, so Slack cannot yet send events happening in your Slack workspace to your Lambda. Let's fix that by adding an AWS Lambda Function URL to your Lambda so that your Lambda can accept HTTP requests + +1. Click on your `bolt_py_oauth_function` Lambda +2. In the **Function Overview**, on the left side, click "Configuration +3. On the left side, click "Function URL" +4. Click "Create function URL" +5. Choose auth type "NONE" +6. Click "Save" + +Phew, congrats! Your Slack app is now accessible to the public. On the right side of your bolt_py_oauth_function Function Overview you should see a your Lambda Function URL. + +1. Copy it - this is the URL your Lambda function is accessible at publicly. +2. We will now inform Slack that this example app can accept Slash Commands. +3. Back on [api.slack.com/apps](https://api.slack.com/apps), select your app and choose "Slash Commands" from the left menu. +4. Click "Create New Command" + 1. By default, the `aws_lambda_oauth.py` function has logic for a /hello-bolt-python-lambda command. Enter `/hello-bolt-python-lambda` as the Command. + * Under **Request URL**, paste in the previously-copied Lambda Function URL. + * Click "Save" +5. We also need to register the API Endpoint as the OAuth redirect URL: + 1. Load up the **OAuth & Permissions** page on[api.slack.com/apps](https://api.slack.com/apps) + 2. Scroll down to "Redirect URLs" + 3. Copy the URL endpoint in - but remove the path portion. The Redirect URL needs to only partially match where we will send users. + +You can now install the app to any workspace! + +### Test it out! +1. Once installed to a Slack workspace, try typing `/hello-bolt-python-lambda` hello. +2. If you have issues, here are some debugging options: + 1. _View lambda activity_: Head to the Monitor tab under your Lambda. Did the Lambda get invoked? Did it respond with an error? Investigate the graphs to see how your Lambda is behaving. + 2. _Check out the logs_: From this same Monitor tab, you can also click "View Logs in CloudWatch" to see the execution logs for your Lambda. This can be helpful to see what errors are being raised. diff --git a/samples/aws_lambda/aws_lambda.py b/examples/aws_lambda/aws_lambda.py similarity index 73% rename from samples/aws_lambda/aws_lambda.py rename to examples/aws_lambda/aws_lambda.py index fdcc33ef4..171de6e2e 100644 --- a/samples/aws_lambda/aws_lambda.py +++ b/examples/aws_lambda/aws_lambda.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging from slack_bolt import App @@ -15,8 +8,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") @@ -38,5 +31,5 @@ def handler(event, context): # export SLACK_BOT_TOKEN=xoxb-*** # rm -rf vendor && cp -pr ../../src/* vendor/ -# pip install python-lambda +# pip install git+https://github.com/nficano/python-lambda # lambda deploy --config-file aws_lambda_config.yaml --requirements requirements.txt diff --git a/samples/aws_lambda/aws_lambda_config.yaml b/examples/aws_lambda/aws_lambda_config.yaml similarity index 85% rename from samples/aws_lambda/aws_lambda_config.yaml rename to examples/aws_lambda/aws_lambda_config.yaml index 5f158892e..5c7c7a6de 100644 --- a/samples/aws_lambda/aws_lambda_config.yaml +++ b/examples/aws_lambda/aws_lambda_config.yaml @@ -4,7 +4,7 @@ function_name: bolt_py_function handler: aws_lambda.handler description: My first lambda function runtime: python3.8 -# role: lambda_basic_execution +role: bolt_python_lambda_invocation # S3 upload requires appropriate role with s3:PutObject permission # (ex. basic_s3_upload), a destination bucket, and the key prefix @@ -20,12 +20,11 @@ aws_secret_access_key: # timeout: 15 # memory_size: 512 # concurrency: 500 -# -# Experimental Environment variables +# Lambda environment variables environment_variables: - SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} # If `tags` is uncommented then tags will be set at creation or update # time. During an update all other tags will be removed except the tags diff --git a/samples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py similarity index 76% rename from samples/aws_lambda/aws_lambda_oauth.py rename to examples/aws_lambda/aws_lambda_oauth.py index 79414b40b..f8fd175d5 100644 --- a/samples/aws_lambda/aws_lambda_oauth.py +++ b/examples/aws_lambda/aws_lambda_oauth.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging from slack_bolt import App @@ -12,12 +5,15 @@ from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow # process_before_response must be True when running on FaaS -app = App(process_before_response=True, oauth_flow=LambdaS3OAuthFlow(),) +app = App( + process_before_response=True, + oauth_flow=LambdaS3OAuthFlow(), +) @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") @@ -46,7 +42,9 @@ def handler(event, context): # AWS IAM Role: bolt_python_s3_storage # - AmazonS3FullAccess # - AWSLambdaBasicExecutionRole +# - AWSLambdaExecute +# - AWSLambdaRole # rm -rf latest_slack_bolt && cp -pr ../../src latest_slack_bolt -# pip install python-lambda +# pip install git+https://github.com/nficano/python-lambda # lambda deploy --config-file aws_lambda_oauth_config.yaml --requirements requirements_oauth.txt diff --git a/samples/aws_lambda/aws_lambda_oauth_config.yaml b/examples/aws_lambda/aws_lambda_oauth_config.yaml similarity index 59% rename from samples/aws_lambda/aws_lambda_oauth_config.yaml rename to examples/aws_lambda/aws_lambda_oauth_config.yaml index 03df079b7..e5e837566 100644 --- a/samples/aws_lambda/aws_lambda_oauth_config.yaml +++ b/examples/aws_lambda/aws_lambda_oauth_config.yaml @@ -1,10 +1,9 @@ region: us-east-1 -function_name: bolt_py_function +function_name: bolt_py_oauth_function handler: aws_lambda_oauth.handler description: My first lambda function runtime: python3.8 -# role: lambda_basic_execution role: bolt_python_s3_storage # S3 upload requires appropriate role with s3:PutObject permission @@ -21,18 +20,15 @@ aws_secret_access_key: # timeout: 15 # memory_size: 512 # concurrency: 500 -# -# Experimental Environment variables +# Lambda environment variables environment_variables: - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} - SLACK_CLIENT_ID: ${SLACK_CLIENT_ID} - SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET} - SLACK_SCOPES: ${SLACK_SCOPES} - SLACK_INSTALLATION_S3_BUCKET_NAME: ${SLACK_INSTALLATION_S3_BUCKET_NAME} - SLACK_STATE_S3_BUCKET_NAME: ${SLACK_STATE_S3_BUCKET_NAME} - SLACK_LAMBDA_PATH: ${SLACK_LAMBDA_PATH} - + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + SLACK_CLIENT_ID: ${SLACK_CLIENT_ID} + SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET} + SLACK_SCOPES: ${SLACK_SCOPES} + SLACK_INSTALLATION_S3_BUCKET_NAME: ${SLACK_INSTALLATION_S3_BUCKET_NAME} + SLACK_STATE_S3_BUCKET_NAME: ${SLACK_STATE_S3_BUCKET_NAME} # If `tags` is uncommented then tags will be set at creation or update # time. During an update all other tags will be removed except the tags @@ -43,4 +39,4 @@ environment_variables: # Build options build: - source_directories: vendor # a comma delimited list of directories in your project root that contains source to package. + source_directories: slack_bolt # a comma delimited list of directories in your project root that contains source to package. diff --git a/samples/aws_lambda/deploy.sh b/examples/aws_lambda/deploy.sh similarity index 63% rename from samples/aws_lambda/deploy.sh rename to examples/aws_lambda/deploy.sh index 0a8f13197..54ca3abdc 100755 --- a/samples/aws_lambda/deploy.sh +++ b/examples/aws_lambda/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash rm -rf vendor && mkdir -p vendor/slack_bolt && cp -pr ../../slack_bolt/* vendor/slack_bolt/ -pip install python-lambda -U +pip install git+https://github.com/nficano/python-lambda lambda deploy \ --config-file aws_lambda_config.yaml \ - --requirements requirements.txt \ No newline at end of file + --requirements requirements.txt diff --git a/examples/aws_lambda/deploy_lazy.sh b/examples/aws_lambda/deploy_lazy.sh new file mode 100755 index 000000000..a76c8165f --- /dev/null +++ b/examples/aws_lambda/deploy_lazy.sh @@ -0,0 +1,6 @@ +#!/bin/bash +rm -rf slack_bolt && mkdir slack_bolt && cp -pr ../../slack_bolt/* slack_bolt/ +pip install git+https://github.com/nficano/python-lambda +lambda deploy \ + --config-file lazy_aws_lambda_config.yaml \ + --requirements requirements.txt diff --git a/examples/aws_lambda/deploy_oauth.sh b/examples/aws_lambda/deploy_oauth.sh new file mode 100755 index 000000000..266aae0f8 --- /dev/null +++ b/examples/aws_lambda/deploy_oauth.sh @@ -0,0 +1,6 @@ +#!/bin/bash +rm -rf slack_bolt && mkdir slack_bolt && cp -pr ../../slack_bolt/* slack_bolt/ +pip install git+https://github.com/nficano/python-lambda +lambda deploy \ + --config-file aws_lambda_oauth_config.yaml \ + --requirements requirements_oauth.txt diff --git a/examples/aws_lambda/lazy_aws_lambda.py b/examples/aws_lambda/lazy_aws_lambda.py new file mode 100644 index 000000000..185e33347 --- /dev/null +++ b/examples/aws_lambda/lazy_aws_lambda.py @@ -0,0 +1,50 @@ +import logging +import time + +from slack_bolt import App +from slack_bolt.adapter.aws_lambda import SlackRequestHandler + +# process_before_response must be True when running on FaaS +app = App(process_before_response=True) + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +command = "/hello-bolt-python-lambda" + + +def respond_to_slack_within_3_seconds(body, ack): + if body.get("text") is None: + ack(f":x: Usage: {command} (description here)") + else: + title = body["text"] + ack(f"Accepted! (task: {title})") + + +def process_request(respond, body): + time.sleep(5) + title = body["text"] + respond(f"Completed! (task: {title})") + + +app.command(command)(ack=respond_to_slack_within_3_seconds, lazy=[process_request]) + +SlackRequestHandler.clear_all_log_handlers() +logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG) + + +def handler(event, context): + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context) + + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** + +# rm -rf vendor && cp -pr ../../src/* vendor/ +# pip install git+https://github.com/nficano/python-lambda +# lambda deploy --config-file lazy_aws_lambda_config.yaml --requirements requirements.txt diff --git a/examples/aws_lambda/lazy_aws_lambda_config.yaml b/examples/aws_lambda/lazy_aws_lambda_config.yaml new file mode 100644 index 000000000..a1ee748d3 --- /dev/null +++ b/examples/aws_lambda/lazy_aws_lambda_config.yaml @@ -0,0 +1,38 @@ +region: us-east-1 + +function_name: bolt_py_function +handler: lazy_aws_lambda.handler +description: My first lambda function +runtime: python3.8 +role: bolt_python_lambda_invocation + +# S3 upload requires appropriate role with s3:PutObject permission +# (ex. basic_s3_upload), a destination bucket, and the key prefix +# bucket_name: 'example-bucket' +# s3_key_prefix: 'path/to/file/' + +# if access key and secret are left blank, boto will use the credentials +# defined in the [default] section of ~/.aws/credentials. +aws_access_key_id: +aws_secret_access_key: + +# dist_directory: dist +# timeout: 15 +# memory_size: 512 +# concurrency: 500 + +# Lambda environment variables +environment_variables: + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + +# If `tags` is uncommented then tags will be set at creation or update +# time. During an update all other tags will be removed except the tags +# listed here. +#tags: +# tag_1: foo +# tag_2: bar + +# Build options +build: + source_directories: slack_bolt # a comma delimited list of directories in your project root that contains source to package. diff --git a/examples/aws_lambda/requirements.txt b/examples/aws_lambda/requirements.txt new file mode 100644 index 000000000..bb964f6ea --- /dev/null +++ b/examples/aws_lambda/requirements.txt @@ -0,0 +1 @@ +slack_sdk \ No newline at end of file diff --git a/examples/aws_lambda/requirements_oauth.txt b/examples/aws_lambda/requirements_oauth.txt new file mode 100644 index 000000000..bb964f6ea --- /dev/null +++ b/examples/aws_lambda/requirements_oauth.txt @@ -0,0 +1 @@ +slack_sdk \ No newline at end of file diff --git a/samples/bottle/app.py b/examples/bottle/app.py similarity index 66% rename from samples/bottle/app.py rename to examples/bottle/app.py index 182266fd8..8cab4d697 100644 --- a/samples/bottle/app.py +++ b/examples/bottle/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -16,14 +9,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(ack, body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/bottle/oauth_app.py b/examples/bottle/oauth_app.py similarity index 71% rename from samples/bottle/oauth_app.py rename to examples/bottle/oauth_app.py index 4a1b30dd7..8d42327c4 100644 --- a/samples/bottle/oauth_app.py +++ b/examples/bottle/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../../src") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.bottle import SlackRequestHandler @@ -14,14 +7,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") @@ -57,4 +50,4 @@ def oauth_redirect(): # export SLACK_CLIENT_SECRET=*** # export SLACK_SCOPES=app_mentions:read,chat:write -# FLASK_APP=oauth_app.py FLASK_ENV=development flask run -p 3000 +# python oauth_app.py diff --git a/samples/bottle/requirements.txt b/examples/bottle/requirements.txt similarity index 100% rename from samples/bottle/requirements.txt rename to examples/bottle/requirements.txt diff --git a/examples/cherrypy/app.py b/examples/cherrypy/app.py new file mode 100644 index 000000000..85eb27797 --- /dev/null +++ b/examples/cherrypy/app.py @@ -0,0 +1,47 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App +from slack_bolt.adapter.cherrypy import SlackRequestHandler + +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +@app.command("/hello-bolt-python") +def hello_command(ack): + ack("Hi from CherryPy") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("What's up?") + + +import cherrypy + +handler = SlackRequestHandler(app) + + +class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def events(self, **kwargs): + return handler.handle() + + +if __name__ == "__main__": + cherrypy.config.update({"server.socket_port": 3000}) + cherrypy.quickstart(SlackApp(), "/slack") + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python app.py diff --git a/examples/cherrypy/oauth_app.py b/examples/cherrypy/oauth_app.py new file mode 100644 index 000000000..17c697e97 --- /dev/null +++ b/examples/cherrypy/oauth_app.py @@ -0,0 +1,61 @@ +import logging +from slack_bolt import App +from slack_bolt.adapter.cherrypy import SlackRequestHandler + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +@app.command("/hello-bolt-python") +def hello_command(ack): + ack("Hi from CherryPy") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("What's up?") + + +import cherrypy + +handler = SlackRequestHandler(app) + + +class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def events(self, **kwargs): + return handler.handle() + + @cherrypy.expose + @cherrypy.tools.slack_in() + def install(self, **kwargs): + return handler.handle() + + @cherrypy.expose + @cherrypy.tools.slack_in() + def oauth_redirect(self, **kwargs): + return handler.handle() + + +if __name__ == "__main__": + cherrypy.config.update({"server.socket_port": 3000}) + cherrypy.quickstart(SlackApp(), "/slack") + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,chat:write + +# python oauth_app.py diff --git a/examples/cherrypy/requirements.txt b/examples/cherrypy/requirements.txt new file mode 100644 index 000000000..42fd09e99 --- /dev/null +++ b/examples/cherrypy/requirements.txt @@ -0,0 +1 @@ +CherryPy>=18,<19 diff --git a/samples/dialogs_app.py b/examples/dialogs_app.py similarity index 51% rename from samples/dialogs_app.py rename to examples/dialogs_app.py index ce9462fec..5f2509b1a 100644 --- a/samples/dialogs_app.py +++ b/examples/dialogs_app.py @@ -1,31 +1,24 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) -from slack_bolt import App +from slack_bolt import App, Ack app = App() @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.command("/hello-bolt-python") -def test_command(payload, client, ack, logger): - logger.info(payload) +def test_command(body, client, ack, logger): + logger.info(body) ack("I got it!") res = client.dialog_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], dialog={ "callback_id": "dialog-callback-id", "title": "Request a Ride", @@ -51,12 +44,53 @@ def test_command(payload, client, ack, logger): logger.info(res) -@app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"}) -def dialog_submission(ack): - ack() - - -@app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"}) +@app.action("dialog-callback-id") +def dialog_submission_or_cancellation(ack: Ack, body: dict): + if body["type"] == "dialog_cancellation": + # This can be sent only when notify_on_cancel is True + ack() + return + + errors = [] + submission = body["submission"] + if len(submission["loc_origin"]) <= 3: + errors = [ + { + "name": "loc_origin", + "error": "Pickup Location must be longer than 3 characters", + } + ] + if len(errors) > 0: + # or ack({"errors": errors}) + ack(errors=errors) + else: + ack() + + +# @app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"}) +# def dialog_submission_or_cancellation(ack: Ack, body: dict): +# errors = [] +# submission = body["submission"] +# if len(submission["loc_origin"]) <= 3: +# errors = [ +# { +# "name": "loc_origin", +# "error": "Pickup Location must be longer than 3 characters" +# } +# ] +# if len(errors) > 0: +# # or ack({"errors": errors}) +# ack(errors=errors) +# else: +# ack() +# +# @app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"}) +# def dialog_cancellation(ack): +# ack() + + +# @app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"}) +@app.options("dialog-callback-id") def dialog_suggestion(ack): ack( { @@ -75,11 +109,6 @@ def dialog_suggestion(ack): ) -@app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"}) -def dialog_cancellation(ack): - ack() - - if __name__ == "__main__": app.start(3000) diff --git a/examples/django/.gitignore b/examples/django/.gitignore new file mode 100644 index 000000000..68712b32b --- /dev/null +++ b/examples/django/.gitignore @@ -0,0 +1,2 @@ +db.sqlite3 +db/ diff --git a/examples/django/README.md b/examples/django/README.md new file mode 100644 index 000000000..cb29a822b --- /dev/null +++ b/examples/django/README.md @@ -0,0 +1,86 @@ +## Bolt for Python - Django integration example + +This example demonstrates how you can use Bolt for Python in your Django application. The project consists of two apps. + +### `simple_app` - Single-workspace App Example + +If you want to run a simple app like the one you've tried in the [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: + +* Create a new Slack app configuration at https://api.slack.com/apps?new_app=1 +* Go to "OAuth & Permissions" + * Add `app_mentions:read`, `chat:write` in Scopes > Bot Token Scopes +* Go to "Install App" + * Click "Install to Workspace" + * Complete the installation flow + * Copy the "Bot User OAuth Token" value, which starts with `xoxb-` + +You can start your Django application this way: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt + +export SLACK_SIGNING_SECRET=(You can find this value at Settings > Basic Information > App Credentials > Signing Secret) +export SLACK_BOT_TOKEN=(You can find this value at Settings > Install App > Bot User OAuth Token) + +python manage.py migrate +python manage.py runserver 0.0.0.0:3000 +``` + +As you did at [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" + * Turn the feature on + * Set the "Request URL" to `https://{your public domain}/slack/events` +* Go to the Slack workspace you've installed this app +* Invite the app's bot user to a channel +* Mention the bot user in the channel +* You'll see a reply from your app's bot user! + +### `oauth_app` - Multiple-workspace App Example (OAuth flow supported) + +By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. + +This example uses SQLite. If you are looking for an example using MySQL, check the `mysql-docker-compose.yml` and the comment in `myslackapp/settings.py`. + + +To run this app, all you need to do are: + +* Create a new Slack app configuration at https://api.slack.com/apps?new_app=1 +* Go to "OAuth & Permissions" + * Add `app_mentions:read`, `chat:write` in Scopes > Bot Token Scopes +* Follow the instructions [here](https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth) for configuring OAuth flow supported Slack apps + +You can start your Django application this way: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt + +export SLACK_SIGNING_SECRET=(You can find this value at Settings > Basic Information > App Credentials > Signing Secret) +export SLACK_CLIENT_ID=(You can find this value at Settings > Basic Information > App Credentials > Client ID) +export SLACK_CLIENT_SECRET=(You can find this value at Settings > Basic Information > App Credentials > Client Secret) +export SLACK_SCOPES=app_mentions:read,chat:write + +python manage.py migrate +python manage.py runserver 0.0.0.0:3000 +``` + +As you did at [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" + * Turn the feature on + * Set the "Request URL" to `https://{your public domain}/slack/events` +* Visit `https://{your public domain}/slack/install` and complete the installation flow +* Add `https://{your public domain}/slack/oauth_redirect` as your redirect URL for your app in Oauth & Permissions on the Slack app configuration page. +* Invite the app's bot user to a channel +* Mention the bot user in the channel +* You'll see a reply from your app's bot user! diff --git a/samples/django/slackapp/manage.py b/examples/django/manage.py similarity index 68% rename from samples/django/slackapp/manage.py rename to examples/django/manage.py index c7ccae1c5..cec2fe4e4 100755 --- a/samples/django/slackapp/manage.py +++ b/examples/django/manage.py @@ -1,20 +1,12 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os - -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt - import sys -sys.path.insert(1, "../../..") - - -# ------------------------------------------------ - def main(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "slackapp.settings") + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/samples/django/slackapp/slackapp/__init__.py b/examples/django/myslackapp/__init__.py similarity index 100% rename from samples/django/slackapp/slackapp/__init__.py rename to examples/django/myslackapp/__init__.py diff --git a/examples/django/myslackapp/asgi.py b/examples/django/myslackapp/asgi.py new file mode 100644 index 000000000..814c38df4 --- /dev/null +++ b/examples/django/myslackapp/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for myslackapp project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") + +application = get_asgi_application() diff --git a/examples/django/myslackapp/settings.py b/examples/django/myslackapp/settings.py new file mode 100644 index 000000000..a99c91188 --- /dev/null +++ b/examples/django/myslackapp/settings.py @@ -0,0 +1,181 @@ +""" +Django settings for myslackapp project. + +Generated by 'django-admin startproject' using Django 3.2.3. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.2/ref/settings/ +""" +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ + +# TODO: CHANGE THIS IF YOU REUSE THIS APP +SECRET_KEY = "This is just a example. You should not expose your secret key in real apps" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + + +# ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "simple_app.apps.SimpleAppConfig", + "oauth_app.apps.OauthAppConfig", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "myslackapp.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "myslackapp.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases + +DATABASES = { + # You can initialize your local database by the following steps: + # + # python manage.py migrate + # python manage.py runserver 0.0.0.0:3000 + # + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + # If you want to use MySQL quickly, the following steps work for you + # + # docker-compose -f mysql-docker-compose.yml up --build + # pip install mysqlclient + # python manage.py migrate + # python manage.py runserver 0.0.0.0:3000 + # + # And then, enable the following setting instead: + # + # "default": { + # "ENGINE": "django.db.backends.mysql", + # "NAME": "slackapp", + # "USER": "app", + # "PASSWORD": "password", + # "HOST": "127.0.0.1", + # "PORT": 33306, + # }, +} + + +# Password validation +# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +STATIC_URL = "/static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), + "propagate": False, + }, + "django.db": { + "level": "DEBUG", + }, + "slack_bolt": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} diff --git a/examples/django/myslackapp/urls.py b/examples/django/myslackapp/urls.py new file mode 100644 index 000000000..f3d5c7268 --- /dev/null +++ b/examples/django/myslackapp/urls.py @@ -0,0 +1,43 @@ +"""myslackapp URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin # noqa: F401 +from django.urls import path + +# Set this flag to False if you want to enable oauth_app instead +is_simple_app = True + +if is_simple_app: + # A simple app that works only for a single Slack workspace + # (prerequisites) + # export SLACK_BOT_TOKEN= + # export SLACK_SIGNING_SECRET= + from simple_app.urls import slack_events_handler + + urlpatterns = [path("slack/events", slack_events_handler)] +else: + # OAuth flow supported app + # (prerequisites) + # export SLACK_CLIENT_ID= + # export SLACK_CLIENT_SECRET= + # export SLACK_SIGNING_SECRET= + # export SLACK_SCOPES=app_mentions:read + from oauth_app.urls import slack_events_handler, slack_oauth_handler + + urlpatterns = [ + path("slack/events", slack_events_handler, name="handle"), + path("slack/install", slack_oauth_handler, name="install"), + path("slack/oauth_redirect", slack_oauth_handler, name="oauth_redirect"), + ] diff --git a/samples/django/slackapp/slackapp/wsgi.py b/examples/django/myslackapp/wsgi.py similarity index 57% rename from samples/django/slackapp/slackapp/wsgi.py rename to examples/django/myslackapp/wsgi.py index efefedc30..4443e81cb 100644 --- a/samples/django/slackapp/slackapp/wsgi.py +++ b/examples/django/myslackapp/wsgi.py @@ -1,16 +1,16 @@ """ -WSGI config for slackapp project. +WSGI config for myslackapp project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "slackapp.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") application = get_wsgi_application() diff --git a/examples/django/mysql-docker-compose.yml b/examples/django/mysql-docker-compose.yml new file mode 100644 index 000000000..e1f543f56 --- /dev/null +++ b/examples/django/mysql-docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.9' +services: + db: + image: mysql:8 + environment: + MYSQL_DATABASE: slackapp + MYSQL_USER: app + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: password + #command: + # - '--wait_timeout=3' + volumes: + - './db:/var/lib/mysql' + ports: + - 33306:3306 + diff --git a/tests/async_scenario_tests/__init__.py b/examples/django/oauth_app/__init__.py similarity index 100% rename from tests/async_scenario_tests/__init__.py rename to examples/django/oauth_app/__init__.py diff --git a/examples/django/oauth_app/apps.py b/examples/django/oauth_app/apps.py new file mode 100644 index 000000000..34a1577d4 --- /dev/null +++ b/examples/django/oauth_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OauthAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "oauth_app" diff --git a/examples/django/oauth_app/migrations/0001_initial.py b/examples/django/oauth_app/migrations/0001_initial.py new file mode 100644 index 000000000..d5e3113c6 --- /dev/null +++ b/examples/django/oauth_app/migrations/0001_initial.py @@ -0,0 +1,110 @@ +# Generated by Django 3.2.3 on 2021-05-24 05:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SlackBot", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.CharField(max_length=32)), + ("app_id", models.CharField(max_length=32)), + ("enterprise_id", models.CharField(max_length=32, null=True)), + ("enterprise_name", models.TextField(null=True)), + ("team_id", models.CharField(max_length=32, null=True)), + ("team_name", models.TextField(null=True)), + ("bot_token", models.TextField(null=True)), + ("bot_id", models.CharField(max_length=32, null=True)), + ("bot_user_id", models.CharField(max_length=32, null=True)), + ("bot_scopes", models.TextField(null=True)), + ("is_enterprise_install", models.BooleanField(null=True)), + ("installed_at", models.DateTimeField()), + ], + ), + migrations.CreateModel( + name="SlackInstallation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("client_id", models.CharField(max_length=32)), + ("app_id", models.CharField(max_length=32)), + ("enterprise_id", models.CharField(max_length=32, null=True)), + ("enterprise_name", models.TextField(null=True)), + ("enterprise_url", models.TextField(null=True)), + ("team_id", models.CharField(max_length=32, null=True)), + ("team_name", models.TextField(null=True)), + ("bot_token", models.TextField(null=True)), + ("bot_id", models.CharField(max_length=32, null=True)), + ("bot_user_id", models.TextField(null=True)), + ("bot_scopes", models.TextField(null=True)), + ("user_id", models.CharField(max_length=32)), + ("user_token", models.TextField(null=True)), + ("user_scopes", models.TextField(null=True)), + ("incoming_webhook_url", models.TextField(null=True)), + ("incoming_webhook_channel", models.TextField(null=True)), + ("incoming_webhook_channel_id", models.TextField(null=True)), + ("incoming_webhook_configuration_url", models.TextField(null=True)), + ("is_enterprise_install", models.BooleanField(null=True)), + ("token_type", models.CharField(max_length=32, null=True)), + ("installed_at", models.DateTimeField()), + ], + ), + migrations.CreateModel( + name="SlackOAuthState", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("state", models.CharField(max_length=64)), + ("expire_at", models.DateTimeField()), + ], + ), + migrations.AddIndex( + model_name="slackinstallation", + index=models.Index( + fields=[ + "client_id", + "enterprise_id", + "team_id", + "user_id", + "installed_at", + ], + name="oauth_app_s_client__f32bc0_idx", + ), + ), + migrations.AddIndex( + model_name="slackbot", + index=models.Index( + fields=["client_id", "enterprise_id", "team_id", "installed_at"], + name="oauth_app_s_client__fe2514_idx", + ), + ), + ] diff --git a/examples/django/oauth_app/migrations/0002_token_rotation.py b/examples/django/oauth_app/migrations/0002_token_rotation.py new file mode 100644 index 000000000..65648d6b2 --- /dev/null +++ b/examples/django/oauth_app/migrations/0002_token_rotation.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.3 on 2021-07-15 23:44 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("oauth_app", "0001_initial"), + ] + + operations = [ + migrations.AddField("SlackBot", "bot_refresh_token", models.TextField(null=True)), + migrations.AddField("SlackBot", "bot_token_expires_at", models.DateTimeField(null=True)), + migrations.AddField("SlackInstallation", "bot_refresh_token", models.TextField(null=True)), + migrations.AddField("SlackInstallation", "bot_token_expires_at", models.DateTimeField(null=True)), + migrations.AddField("SlackInstallation", "user_refresh_token", models.TextField(null=True)), + migrations.AddField( + "SlackInstallation", + "user_token_expires_at", + models.DateTimeField(null=True), + ), + ] diff --git a/examples/django/oauth_app/migrations/__init__.py b/examples/django/oauth_app/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/django/oauth_app/models.py b/examples/django/oauth_app/models.py new file mode 100644 index 000000000..ff95d9792 --- /dev/null +++ b/examples/django/oauth_app/models.py @@ -0,0 +1,73 @@ +# ---------------------- +# Database tables +# ---------------------- + +from django.db import models + + +class SlackBot(models.Model): + client_id = models.CharField(null=False, max_length=32) + app_id = models.CharField(null=False, max_length=32) + enterprise_id = models.CharField(null=True, max_length=32) + enterprise_name = models.TextField(null=True) + team_id = models.CharField(null=True, max_length=32) + team_name = models.TextField(null=True) + bot_token = models.TextField(null=True) + bot_refresh_token = models.TextField(null=True) + bot_token_expires_at = models.DateTimeField(null=True) + bot_id = models.CharField(null=True, max_length=32) + bot_user_id = models.CharField(null=True, max_length=32) + bot_scopes = models.TextField(null=True) + is_enterprise_install = models.BooleanField(null=True) + installed_at = models.DateTimeField(null=False) + + class Meta: + indexes = [ + models.Index(fields=["client_id", "enterprise_id", "team_id", "installed_at"]), + ] + + +class SlackInstallation(models.Model): + client_id = models.CharField(null=False, max_length=32) + app_id = models.CharField(null=False, max_length=32) + enterprise_id = models.CharField(null=True, max_length=32) + enterprise_name = models.TextField(null=True) + enterprise_url = models.TextField(null=True) + team_id = models.CharField(null=True, max_length=32) + team_name = models.TextField(null=True) + bot_token = models.TextField(null=True) + bot_refresh_token = models.TextField(null=True) + bot_token_expires_at = models.DateTimeField(null=True) + bot_id = models.CharField(null=True, max_length=32) + bot_user_id = models.TextField(null=True) + bot_scopes = models.TextField(null=True) + user_id = models.CharField(null=False, max_length=32) + user_token = models.TextField(null=True) + user_refresh_token = models.TextField(null=True) + user_token_expires_at = models.DateTimeField(null=True) + user_scopes = models.TextField(null=True) + incoming_webhook_url = models.TextField(null=True) + incoming_webhook_channel = models.TextField(null=True) + incoming_webhook_channel_id = models.TextField(null=True) + incoming_webhook_configuration_url = models.TextField(null=True) + is_enterprise_install = models.BooleanField(null=True) + token_type = models.CharField(null=True, max_length=32) + installed_at = models.DateTimeField(null=False) + + class Meta: + indexes = [ + models.Index( + fields=[ + "client_id", + "enterprise_id", + "team_id", + "user_id", + "installed_at", + ] + ), + ] + + +class SlackOAuthState(models.Model): + state = models.CharField(null=False, max_length=64) + expire_at = models.DateTimeField(null=False) diff --git a/examples/django/oauth_app/slack_datastores.py b/examples/django/oauth_app/slack_datastores.py new file mode 100644 index 000000000..90692d1ee --- /dev/null +++ b/examples/django/oauth_app/slack_datastores.py @@ -0,0 +1,211 @@ +# ---------------------- +# Bolt store implementations +# ---------------------- + +from logging import Logger +from typing import Optional +from uuid import uuid4 +from django.db.models import F +from django.utils import timezone +from django.utils.timezone import is_naive, make_aware +from slack_sdk.oauth import InstallationStore, OAuthStateStore +from slack_sdk.oauth.installation_store import Bot, Installation + +from .models import SlackBot, SlackInstallation, SlackOAuthState + + +class DjangoInstallationStore(InstallationStore): + client_id: str + + def __init__( + self, + client_id: str, + logger: Logger, + ): + self.client_id = client_id + self._logger = logger + + @property + def logger(self) -> Logger: + return self._logger + + def save(self, installation: Installation): + i = installation.to_dict() + if is_naive(i["installed_at"]): + i["installed_at"] = make_aware(i["installed_at"]) + if i.get("bot_token_expires_at") is not None and is_naive(i["bot_token_expires_at"]): + i["bot_token_expires_at"] = make_aware(i["bot_token_expires_at"]) + if i.get("user_token_expires_at") is not None and is_naive(i["user_token_expires_at"]): + i["user_token_expires_at"] = make_aware(i["user_token_expires_at"]) + i["client_id"] = self.client_id + row_to_update = ( + SlackInstallation.objects.filter(client_id=self.client_id) + .filter(enterprise_id=installation.enterprise_id) + .filter(team_id=installation.team_id) + .filter(installed_at=i["installed_at"]) + .first() + ) + if row_to_update is not None: + for key, value in i.items(): + setattr(row_to_update, key, value) + row_to_update.save() + else: + SlackInstallation(**i).save() + + self.save_bot(installation.to_bot()) + + def save_bot(self, bot: Bot): + b = bot.to_dict() + if is_naive(b["installed_at"]): + b["installed_at"] = make_aware(b["installed_at"]) + if b.get("bot_token_expires_at") is not None and is_naive(b["bot_token_expires_at"]): + b["bot_token_expires_at"] = make_aware(b["bot_token_expires_at"]) + b["client_id"] = self.client_id + + row_to_update = ( + SlackBot.objects.filter(client_id=self.client_id) + .filter(enterprise_id=bot.enterprise_id) + .filter(team_id=bot.team_id) + .filter(installed_at=b["installed_at"]) + .first() + ) + if row_to_update is not None: + for key, value in b.items(): + setattr(row_to_update, key, value) + row_to_update.save() + else: + SlackBot(**b).save() + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + e_id = enterprise_id or None + t_id = team_id or None + if is_enterprise_install: + t_id = None + rows = ( + SlackBot.objects.filter(client_id=self.client_id) + .filter(enterprise_id=e_id) + .filter(team_id=t_id) + .order_by(F("installed_at").desc())[:1] + ) + if len(rows) > 0: + b = rows[0] + return Bot( + app_id=b.app_id, + enterprise_id=b.enterprise_id, + team_id=b.team_id, + bot_token=b.bot_token, + bot_refresh_token=b.bot_refresh_token, + bot_token_expires_at=b.bot_token_expires_at, + bot_id=b.bot_id, + bot_user_id=b.bot_user_id, + bot_scopes=b.bot_scopes, + installed_at=b.installed_at, + ) + return None + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + e_id = enterprise_id or None + t_id = team_id or None + if is_enterprise_install: + t_id = None + if user_id is None: + rows = ( + SlackInstallation.objects.filter(client_id=self.client_id) + .filter(enterprise_id=e_id) + .filter(team_id=t_id) + .order_by(F("installed_at").desc())[:1] + ) + else: + rows = ( + SlackInstallation.objects.filter(client_id=self.client_id) + .filter(enterprise_id=e_id) + .filter(team_id=t_id) + .filter(user_id=user_id) + .order_by(F("installed_at").desc())[:1] + ) + + if len(rows) > 0: + i = rows[0] + if user_id is not None: + # Fetch the latest bot token + latest_bot_rows = ( + SlackInstallation.objects.filter(client_id=self.client_id) + .exclude(bot_token__isnull=True) + .filter(enterprise_id=e_id) + .filter(team_id=t_id) + .order_by(F("installed_at").desc())[:1] + ) + if len(latest_bot_rows) > 0: + b = latest_bot_rows[0] + i.bot_id = b.bot_id + i.bot_user_id = b.bot_user_id + i.bot_scopes = b.bot_scopes + i.bot_token = b.bot_token + i.bot_refresh_token = b.bot_refresh_token + i.bot_token_expires_at = b.bot_token_expires_at + + return Installation( + app_id=i.app_id, + enterprise_id=i.enterprise_id, + team_id=i.team_id, + bot_token=i.bot_token, + bot_refresh_token=i.bot_refresh_token, + bot_token_expires_at=i.bot_token_expires_at, + bot_id=i.bot_id, + bot_user_id=i.bot_user_id, + bot_scopes=i.bot_scopes, + user_id=i.user_id, + user_token=i.user_token, + user_refresh_token=i.user_refresh_token, + user_token_expires_at=i.user_token_expires_at, + user_scopes=i.user_scopes, + incoming_webhook_url=i.incoming_webhook_url, + incoming_webhook_channel_id=i.incoming_webhook_channel_id, + incoming_webhook_configuration_url=i.incoming_webhook_configuration_url, + installed_at=i.installed_at, + ) + return None + + +class DjangoOAuthStateStore(OAuthStateStore): + expiration_seconds: int + + def __init__( + self, + expiration_seconds: int, + logger: Logger, + ): + self.expiration_seconds = expiration_seconds + self._logger = logger + + @property + def logger(self) -> Logger: + return self._logger + + def issue(self) -> str: + state: str = str(uuid4()) + expire_at = timezone.now() + timezone.timedelta(seconds=self.expiration_seconds) + row = SlackOAuthState(state=state, expire_at=expire_at) + row.save() + return state + + def consume(self, state: str) -> bool: + rows = SlackOAuthState.objects.filter(state=state).filter(expire_at__gte=timezone.now()) + if len(rows) > 0: + for row in rows: + row.delete() + return True + return False diff --git a/examples/django/oauth_app/slack_listeners.py b/examples/django/oauth_app/slack_listeners.py new file mode 100644 index 000000000..7d10838f6 --- /dev/null +++ b/examples/django/oauth_app/slack_listeners.py @@ -0,0 +1,75 @@ +import logging +import os + +from slack_bolt import App, BoltContext +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.webhook import WebhookClient + +# Database models +from .models import SlackInstallation +from django.db.models import F + +# Bolt datastore implementations +from .slack_datastores import DjangoInstallationStore, DjangoOAuthStateStore + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret, scopes, user_scopes = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], + os.environ.get("SLACK_SCOPES", "commands").split(","), + os.environ.get("SLACK_USER_SCOPES", "search:read").split(","), +) + +app = App( + signing_secret=signing_secret, + oauth_settings=OAuthSettings( + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + user_scopes=user_scopes, + # If you want to test token rotation, enabling the following line will make it easy + # token_rotation_expiration_minutes=1000000, + installation_store=DjangoInstallationStore( + client_id=client_id, + logger=logger, + ), + state_store=DjangoOAuthStateStore( + expiration_seconds=120, + logger=logger, + ), + ), +) + + +def event_test(body, say, context: BoltContext, logger): + logger.info(body) + say(":wave: What's up?") + + found_rows = list( + SlackInstallation.objects.filter(enterprise_id=context.enterprise_id) + .filter(team_id=context.team_id) + .filter(incoming_webhook_url__isnull=False) + .order_by(F("installed_at").desc())[:1] + ) + if len(found_rows) > 0: + webhook_url = found_rows[0].incoming_webhook_url + logger.info(f"webhook_url: {webhook_url}") + client = WebhookClient(webhook_url) + client.send(text=":wave: This is a message posted using Incoming Webhook!") + + +# lazy listener example +def noop(): + pass + + +app.event("app_mention")( + ack=event_test, + lazy=[noop], +) + + +@app.command("/hello-django-app") +def command(ack): + ack(":wave: Hello from a Django app :smile:") diff --git a/examples/django/oauth_app/urls.py b/examples/django/oauth_app/urls.py new file mode 100644 index 000000000..7802c7e54 --- /dev/null +++ b/examples/django/oauth_app/urls.py @@ -0,0 +1,25 @@ +from django.urls import path + +from django.http import HttpRequest +from django.views.decorators.csrf import csrf_exempt + +from slack_bolt.adapter.django import SlackRequestHandler +from .slack_listeners import app + +handler = SlackRequestHandler(app=app) + + +@csrf_exempt +def slack_events_handler(request: HttpRequest): + return handler.handle(request) + + +def slack_oauth_handler(request: HttpRequest): + return handler.handle(request) + + +urlpatterns = [ + path("slack/events", slack_events_handler, name="handle"), + path("slack/install", slack_oauth_handler, name="install"), + path("slack/oauth_redirect", slack_oauth_handler, name="oauth_redirect"), +] diff --git a/examples/django/requirements.txt b/examples/django/requirements.txt new file mode 100644 index 000000000..03d2a394a --- /dev/null +++ b/examples/django/requirements.txt @@ -0,0 +1,2 @@ +Django>=3.2,<4 +slack-bolt>=1.7,<2 diff --git a/examples/django/simple_app/__init__.py b/examples/django/simple_app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/django/simple_app/apps.py b/examples/django/simple_app/apps.py new file mode 100644 index 000000000..d80d269e9 --- /dev/null +++ b/examples/django/simple_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SimpleAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "simple_app" diff --git a/examples/django/simple_app/migrations/__init__.py b/examples/django/simple_app/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/django/simple_app/models.py b/examples/django/simple_app/models.py new file mode 100644 index 000000000..82c4e7854 --- /dev/null +++ b/examples/django/simple_app/models.py @@ -0,0 +1,3 @@ +from django.db import models # noqa: F401 + +# Create your models here. diff --git a/examples/django/simple_app/slack_listeners.py b/examples/django/simple_app/slack_listeners.py new file mode 100644 index 000000000..c066ead1c --- /dev/null +++ b/examples/django/simple_app/slack_listeners.py @@ -0,0 +1,19 @@ +import logging +import os + +from slack_bolt import App + +logger = logging.getLogger(__name__) + +app = App( + token=os.environ["SLACK_BOT_TOKEN"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + # disable eagerly verifying the given SLACK_BOT_TOKEN value + token_verification_enabled=False, +) + + +@app.event("app_mention") +def handle_app_mentions(logger, event, say): + logger.info(event) + say(f"Hi there, <@{event['user']}>") diff --git a/samples/django/slackapp/slackapp/views.py b/examples/django/simple_app/urls.py similarity index 55% rename from samples/django/slackapp/slackapp/views.py rename to examples/django/simple_app/urls.py index 1ffabcddb..dfdd1291e 100644 --- a/samples/django/slackapp/slackapp/views.py +++ b/examples/django/simple_app/urls.py @@ -1,21 +1,19 @@ -from django.http import HttpRequest -from django.views.decorators.csrf import csrf_exempt +from django.urls import path -from slack_bolt import App from slack_bolt.adapter.django import SlackRequestHandler - -app = App() - - -@app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) - say("What's up?") - +from .slack_listeners import app handler = SlackRequestHandler(app=app) +from django.http import HttpRequest +from django.views.decorators.csrf import csrf_exempt + @csrf_exempt -def handle(request: HttpRequest): +def slack_events_handler(request: HttpRequest): return handler.handle(request) + + +urlpatterns = [ + path("slack/events", slack_events_handler, name="slack_events"), +] diff --git a/examples/docker/aiohttp/Dockerfile b/examples/docker/aiohttp/Dockerfile new file mode 100644 index 000000000..a7b78ed12 --- /dev/null +++ b/examples/docker/aiohttp/Dockerfile @@ -0,0 +1,19 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +RUN apt-get update && apt-get clean +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +COPY --from=builder /build/ /app/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +WORKDIR /app/ +COPY *.py /app/ +ENTRYPOINT python main.py + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# \ No newline at end of file diff --git a/examples/docker/aiohttp/main.py b/examples/docker/aiohttp/main.py new file mode 100644 index 000000000..f55a333b8 --- /dev/null +++ b/examples/docker/aiohttp/main.py @@ -0,0 +1,17 @@ +import os +import logging + +from slack_bolt.async_app import AsyncApp + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +@app.command("/hello-bolt-python") +async def hello(body, ack): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", int(os.environ.get("PORT", 3000))))) diff --git a/examples/docker/aiohttp/requirements.txt b/examples/docker/aiohttp/requirements.txt new file mode 100644 index 000000000..9ffe0006a --- /dev/null +++ b/examples/docker/aiohttp/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt +aiohttp>=3,<4 \ No newline at end of file diff --git a/examples/docker/asgi/Dockerfile b/examples/docker/asgi/Dockerfile new file mode 100644 index 000000000..cef704967 --- /dev/null +++ b/examples/docker/asgi/Dockerfile @@ -0,0 +1,18 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.11-alpine3.17 as builder +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.11-alpine3.17 as app +WORKDIR /app/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +COPY main.py /app/ +ENTRYPOINT uvicorn main:asgi_app --port $PORT --workers 1 + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# diff --git a/examples/docker/asgi/main.py b/examples/docker/asgi/main.py new file mode 100644 index 000000000..1da17f94e --- /dev/null +++ b/examples/docker/asgi/main.py @@ -0,0 +1,15 @@ +import logging +from slack_bolt import App +from slack_bolt.adapter.asgi import SlackRequestHandler + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +asgi_app = SlackRequestHandler(app) diff --git a/examples/docker/asgi/requirements.txt b/examples/docker/asgi/requirements.txt new file mode 100644 index 000000000..097e2d17e --- /dev/null +++ b/examples/docker/asgi/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt +uvicorn<1 diff --git a/examples/docker/fastapi-gunicorn/Dockerfile b/examples/docker/fastapi-gunicorn/Dockerfile new file mode 100644 index 000000000..c8f8b94e3 --- /dev/null +++ b/examples/docker/fastapi-gunicorn/Dockerfile @@ -0,0 +1,13 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM tiangolo/uvicorn-gunicorn:python3.8-slim +WORKDIR /app/ +COPY *.py /app/ +COPY requirements.txt /app/ +RUN pip install -U pip && pip install -r requirements.txt + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e VARIABLE_NAME="api" -p 80:80 -it your-repo/hello-bolt +# or +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e VARIABLE_NAME="api" -p 3000:80 -it your-repo/hello-bolt diff --git a/examples/docker/fastapi-gunicorn/main.py b/examples/docker/fastapi-gunicorn/main.py new file mode 100644 index 000000000..b118a6f1a --- /dev/null +++ b/examples/docker/fastapi-gunicorn/main.py @@ -0,0 +1,26 @@ +import logging + +from slack_bolt.async_app import AsyncApp + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +@app.command("/hello-bolt-python") +async def hello(body, ack): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +from fastapi import FastAPI, Request + +api = FastAPI() + +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler + +app_handler = AsyncSlackRequestHandler(app) + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) diff --git a/examples/docker/fastapi-gunicorn/requirements.txt b/examples/docker/fastapi-gunicorn/requirements.txt new file mode 100644 index 000000000..e005ac5e5 --- /dev/null +++ b/examples/docker/fastapi-gunicorn/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +fastapi<1 +aiohttp>=3,<4 \ No newline at end of file diff --git a/examples/docker/flask-gunicorn/Dockerfile b/examples/docker/flask-gunicorn/Dockerfile new file mode 100644 index 000000000..e788cb388 --- /dev/null +++ b/examples/docker/flask-gunicorn/Dockerfile @@ -0,0 +1,18 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +WORKDIR /app/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +COPY *.py /app/ +ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# \ No newline at end of file diff --git a/examples/docker/flask-gunicorn/main.py b/examples/docker/flask-gunicorn/main.py new file mode 100644 index 000000000..6eecad39b --- /dev/null +++ b/examples/docker/flask-gunicorn/main.py @@ -0,0 +1,24 @@ +import logging + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hello-bolt-python") +def hello(body, ack): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) diff --git a/examples/docker/flask-gunicorn/requirements.txt b/examples/docker/flask-gunicorn/requirements.txt new file mode 100644 index 000000000..24cc3e223 --- /dev/null +++ b/examples/docker/flask-gunicorn/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>=1.1 +gunicorn>=20 \ No newline at end of file diff --git a/examples/docker/flask-uwsgi/Dockerfile b/examples/docker/flask-uwsgi/Dockerfile new file mode 100644 index 000000000..14ddfb41b --- /dev/null +++ b/examples/docker/flask-uwsgi/Dockerfile @@ -0,0 +1,22 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +RUN apt-get update \ + && apt-get -y install build-essential libpcre3-dev \ + && apt-get clean +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +WORKDIR /app/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +COPY *.py /app/ +COPY uwsgi.ini /app/ +ENTRYPOINT uwsgi --ini uwsgi.ini --http :$PORT + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# \ No newline at end of file diff --git a/examples/docker/flask-uwsgi/main.py b/examples/docker/flask-uwsgi/main.py new file mode 100644 index 000000000..6eecad39b --- /dev/null +++ b/examples/docker/flask-uwsgi/main.py @@ -0,0 +1,24 @@ +import logging + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hello-bolt-python") +def hello(body, ack): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) diff --git a/examples/docker/flask-uwsgi/requirements.txt b/examples/docker/flask-uwsgi/requirements.txt new file mode 100644 index 000000000..f768869b4 --- /dev/null +++ b/examples/docker/flask-uwsgi/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>1 +uWSGI>=2,<3 \ No newline at end of file diff --git a/examples/docker/flask-uwsgi/uwsgi.ini b/examples/docker/flask-uwsgi/uwsgi.ini new file mode 100644 index 000000000..c2a089723 --- /dev/null +++ b/examples/docker/flask-uwsgi/uwsgi.ini @@ -0,0 +1,7 @@ +[uwsgi] +module = main:flask_app +master = true +uid = nobody +socket = /tmp/uwsgi.sock +die-on-term = true +enable-threads = true \ No newline at end of file diff --git a/examples/docker/sanic/Dockerfile b/examples/docker/sanic/Dockerfile new file mode 100644 index 000000000..4606e68bd --- /dev/null +++ b/examples/docker/sanic/Dockerfile @@ -0,0 +1,18 @@ +# +# docker build . -t your-repo/hello-bolt +# +FROM python:3.8.5-slim-buster as builder +COPY requirements.txt /build/ +WORKDIR /build/ +RUN pip install -U pip && pip install -r requirements.txt + +FROM python:3.8.5-slim-buster as app +WORKDIR /app/ +COPY *.py /app/ +COPY --from=builder /usr/local/bin/ /usr/local/bin/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +ENTRYPOINT python main.py + +# +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e PORT=3000 -p 3000:3000 -it your-repo/hello-bolt +# diff --git a/examples/docker/sanic/main.py b/examples/docker/sanic/main.py new file mode 100644 index 000000000..04203c8a5 --- /dev/null +++ b/examples/docker/sanic/main.py @@ -0,0 +1,30 @@ +import logging +import os + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.command("/hello-bolt-python") +async def hello(body, ack): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +from sanic import Sanic +from sanic.request import Request + +api = Sanic(name="awesome-slack-app") + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/examples/docker/sanic/requirements.txt b/examples/docker/sanic/requirements.txt new file mode 100644 index 000000000..f72449c0e --- /dev/null +++ b/examples/docker/sanic/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +aiohttp>=3,<4 +sanic>=20,<21 \ No newline at end of file diff --git a/examples/events_app.py b/examples/events_app.py new file mode 100644 index 000000000..459c7cbbe --- /dev/null +++ b/examples/events_app.py @@ -0,0 +1,49 @@ +import re +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App + +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("What's up?") + + +@app.event("reaction_added") +def say_something_to_reaction(say): + say("OK!") + + +@app.message("test") +def test_message(logger, body): + logger.info(body) + + +@app.message(re.compile("bug")) +def mention_bug(logger, body): + logger.info(body) + + +@app.event("message") +def ack_the_rest_of_message_events(logger, body): + logger.info(body) + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python events_app.py diff --git a/samples/falcon/app.py b/examples/falcon/app.py similarity index 58% rename from samples/falcon/app.py rename to examples/falcon/app.py index 59e2764ed..83bb5e460 100644 --- a/samples/falcon/app.py +++ b/examples/falcon/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import falcon import logging import re @@ -16,9 +9,9 @@ app = App() -# @app.command("/bolt-py-proto", [lambda payload: payload["team_id"] == "T03E94MJU"]) -def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respond): - logger.info(payload) +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +def test_command(logger: logging.Logger, body: dict, ack: Ack, respond: Respond): + logger.info(body) ack("thanks!") respond( blocks=[ @@ -40,26 +33,38 @@ def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respo ) -app.command(re.compile(r"/bolt-.+"))(test_command) +app.command(re.compile(r"/hello-bolt-.+"))(test_command) @app.shortcut("test-shortcut") -def test_shortcut(ack, client: WebClient, logger, payload): - logger.info(payload) +def test_shortcut(ack, client: WebClient, logger, body): + logger.info(body) ack() res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, @@ -68,22 +73,21 @@ def test_shortcut(ack, client: WebClient, logger, payload): @app.view("view-id") -def view_submission(ack, payload, logger): - logger.info(payload) +def view_submission(ack, body, logger): + logger.info(body) ack() @app.action("a") -def button_click(logger, payload, say, ack, respond): - logger.info(payload) +def button_click(logger, action, ack, respond): + logger.info(action) ack() - respond("respond!") - # say(text="say!") + respond("Here is my response") @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/examples/falcon/async_app.py b/examples/falcon/async_app.py new file mode 100644 index 000000000..44989d4fa --- /dev/null +++ b/examples/falcon/async_app.py @@ -0,0 +1,100 @@ +import falcon +import logging +import re +from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck +from slack_bolt.adapter.falcon import AsyncSlackAppResource + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): + logger.info(body) + await ack("thanks!") + await respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +app.command(re.compile(r"/hello-bolt-.+"))(test_command) + + +@app.shortcut("test-shortcut") +async def test_shortcut(ack, client, logger, body): + logger.info(body) + await ack() + res = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +async def view_submission(ack, body, logger): + logger.info(body) + await ack() + + +@app.action("a") +async def button_click(logger, action, ack, respond): + logger.info(action) + await ack() + await respond("Here is my response") + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = falcon.asgi.App() +resource = AsyncSlackAppResource(app) +api.add_route("/slack/events", resource) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn --reload -h 0.0.0.0 -p 3000 async_app:api diff --git a/examples/falcon/async_oauth_app.py b/examples/falcon/async_oauth_app.py new file mode 100644 index 000000000..df4d96933 --- /dev/null +++ b/examples/falcon/async_oauth_app.py @@ -0,0 +1,102 @@ +import falcon +import logging +import re +from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck +from slack_bolt.adapter.falcon import AsyncSlackAppResource + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): + logger.info(body) + await ack("thanks!") + await respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +app.command(re.compile(r"/hello-bolt-.+"))(test_command) + + +@app.shortcut("test-shortcut") +async def test_shortcut(ack, client, logger, body): + logger.info(body) + await ack() + res = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +async def view_submission(ack, body, logger): + logger.info(body) + await ack() + + +@app.action("a") +async def button_click(logger, action, ack, respond): + logger.info(action) + await ack() + await respond("Here is my response") + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = falcon.asgi.App() +resource = AsyncSlackAppResource(app) +api.add_route("/slack/events", resource) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn --reload -h 0.0.0.0 -p 3000 async_oauth_app:api +api.add_route("/slack/install", resource) +api.add_route("/slack/oauth_redirect", resource) diff --git a/samples/falcon/oauth_app.py b/examples/falcon/oauth_app.py similarity index 58% rename from samples/falcon/oauth_app.py rename to examples/falcon/oauth_app.py index 675711eef..7d9ad7f1f 100644 --- a/samples/falcon/oauth_app.py +++ b/examples/falcon/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import falcon import logging import re @@ -16,9 +9,9 @@ app = App() -# @app.command("/bolt-py-proto", [lambda payload: payload["team_id"] == "T03E94MJU"]) -def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respond): - logger.info(payload) +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +def test_command(logger: logging.Logger, body: dict, ack: Ack, respond: Respond): + logger.info(body) ack("thanks!") respond( blocks=[ @@ -40,26 +33,38 @@ def test_command(logger: logging.Logger, payload: dict, ack: Ack, respond: Respo ) -app.command(re.compile(r"/bolt-.+"))(test_command) +app.command(re.compile(r"/hello-bolt-.+"))(test_command) @app.shortcut("test-shortcut") -def test_shortcut(ack, client: WebClient, logger, payload): - logger.info(payload) +def test_shortcut(ack, client: WebClient, logger, body): + logger.info(body) ack() res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, @@ -68,25 +73,35 @@ def test_shortcut(ack, client: WebClient, logger, payload): @app.view("view-id") -def view_submission(ack, payload, logger): - logger.info(payload) +def view_submission(ack, body, logger): + logger.info(body) ack() @app.action("a") -def button_click(logger, payload, say, ack, respond): - logger.info(payload) +def button_click(logger, action, ack, respond): + logger.info(action) ack() - respond("respond!") + respond("Here is my response.") # say(text="say!") @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") +@app.message("What") +def handle_matched_messages(event, logger): + logger.info(f"message matched: {event['text']}") + + +@app.event("message") +def handle_messages(event, logger): + logger.info(f"subtype: {event.get('subytype')}") + + api = falcon.API() resource = SlackAppResource(app) api.add_route("/slack/events", resource) diff --git a/examples/falcon/requirements.txt b/examples/falcon/requirements.txt new file mode 100644 index 000000000..d72e1e1d0 --- /dev/null +++ b/examples/falcon/requirements.txt @@ -0,0 +1,3 @@ +falcon>=2,<3 +gunicorn>=20,<21 +uvicorn diff --git a/samples/fastapi/app.py b/examples/fastapi/app.py similarity index 59% rename from samples/fastapi/app.py rename to examples/fastapi/app.py index a196d9226..3bd275ce9 100644 --- a/samples/fastapi/app.py +++ b/examples/fastapi/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.fastapi import SlackRequestHandler @@ -13,11 +6,16 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() @@ -31,4 +29,4 @@ async def endpoint(req: Request): # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** -# uvicorn app:api --reload --port 3000 --log-level debug +# uvicorn app:api --reload --port 3000 --log-level warning diff --git a/samples/fastapi/async_app.py b/examples/fastapi/async_app.py similarity index 52% rename from samples/fastapi/async_app.py rename to examples/fastapi/async_app.py index dee09de17..265cb3b3e 100644 --- a/samples/fastapi/async_app.py +++ b/examples/fastapi/async_app.py @@ -1,23 +1,25 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys +import logging -sys.path.insert(1, "../..") -# ------------------------------------------------ +logging.basicConfig(level=logging.DEBUG) from slack_bolt.async_app import AsyncApp -from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler app = AsyncApp() app_handler = AsyncSlackRequestHandler(app) @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") +@app.event("message") +async def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() @@ -31,4 +33,4 @@ async def endpoint(req: Request): # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** -# uvicorn async_app:api --reload --port 3000 --log-level debug +# uvicorn async_app:api --reload --port 3000 --log-level warning diff --git a/examples/fastapi/async_app_custom_props.py b/examples/fastapi/async_app_custom_props.py new file mode 100644 index 000000000..497e47aa2 --- /dev/null +++ b/examples/fastapi/async_app_custom_props.py @@ -0,0 +1,41 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler + +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_app_mentions(context, say, logger): + logger.info(context) + assert context.get("foo") == "FOO" + await say("What's up?") + + +@app.event("message") +async def handle_message(): + pass + + +from fastapi import FastAPI, Request, Depends + +api = FastAPI() + + +def get_foo(): + yield "FOO" + + +@api.post("/slack/events") +async def endpoint(req: Request, foo: str = Depends(get_foo)): + return await app_handler.handle(req, {"foo": foo}) + + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn async_app_custom_props:api --reload --port 3000 --log-level warning diff --git a/samples/fastapi/async_oauth_app.py b/examples/fastapi/async_oauth_app.py similarity index 72% rename from samples/fastapi/async_oauth_app.py rename to examples/fastapi/async_oauth_app.py index 0dc3a62e3..f6cdbade5 100644 --- a/samples/fastapi/async_oauth_app.py +++ b/examples/fastapi/async_oauth_app.py @@ -1,23 +1,25 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys +import logging -sys.path.insert(1, "../..") -# ------------------------------------------------ +logging.basicConfig(level=logging.DEBUG) from slack_bolt.async_app import AsyncApp -from slack_bolt.adapter.fastapi import AsyncSlackRequestHandler +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler app = AsyncApp() app_handler = AsyncSlackRequestHandler(app) @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") +@app.event("message") +async def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() diff --git a/samples/fastapi/oauth_app.py b/examples/fastapi/oauth_app.py similarity index 71% rename from samples/fastapi/oauth_app.py rename to examples/fastapi/oauth_app.py index 9075a2be5..1ce71fc59 100644 --- a/samples/fastapi/oauth_app.py +++ b/examples/fastapi/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.fastapi import SlackRequestHandler @@ -13,11 +6,16 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +def handle_message(): + pass + + from fastapi import FastAPI, Request api = FastAPI() @@ -46,4 +44,4 @@ async def oauth_redirect(req: Request): # export SLACK_CLIENT_SECRET=*** # export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write -# uvicorn oauth_app:api --reload --port 3000 --log-level debug +# uvicorn oauth_app:api --reload --port 3000 --log-level warning diff --git a/samples/fastapi/requirements.txt b/examples/fastapi/requirements.txt similarity index 100% rename from samples/fastapi/requirements.txt rename to examples/fastapi/requirements.txt diff --git a/samples/flask/app.py b/examples/flask/app.py similarity index 66% rename from samples/flask/app.py rename to examples/flask/app.py index fd8e62d9d..ec37fddd4 100644 --- a/samples/flask/app.py +++ b/examples/flask/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -16,17 +9,22 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +def handle_message(): + pass + + from flask import Flask, request flask_app = Flask(__name__) diff --git a/samples/flask/oauth_app.py b/examples/flask/oauth_app.py similarity index 75% rename from samples/flask/oauth_app.py rename to examples/flask/oauth_app.py index 962f85009..d48391897 100644 --- a/samples/flask/oauth_app.py +++ b/examples/flask/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.flask import SlackRequestHandler @@ -14,17 +7,22 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") +@app.event("message") +def handle_message(): + pass + + from flask import Flask, request flask_app = Flask(__name__) diff --git a/samples/flask/requirements.txt b/examples/flask/requirements.txt similarity index 100% rename from samples/flask/requirements.txt rename to examples/flask/requirements.txt diff --git a/examples/getting_started/.gitignore b/examples/getting_started/.gitignore new file mode 100644 index 000000000..bdfe36378 --- /dev/null +++ b/examples/getting_started/.gitignore @@ -0,0 +1,3 @@ +# Python +.venv/ +env*/ \ No newline at end of file diff --git a/examples/getting_started/README.md b/examples/getting_started/README.md new file mode 100644 index 000000000..48f9d4095 --- /dev/null +++ b/examples/getting_started/README.md @@ -0,0 +1,47 @@ +# Getting Started with ⚡️ Bolt for Python +> Slack app example from 📚 [Building an App with Bolt for Python][1] + +## Overview + +This is a Slack app built with the [Bolt for Python framework][2] that showcases +responding to events and interactive buttons. + +## Running locally + +### 1. Setup environment variables + +```zsh +# Replace with your signing secret and token +export SLACK_BOT_TOKEN= +export SLACK_SIGNING_SECRET= +``` + +### 2. Setup your local project + +```zsh +# Clone this project onto your machine +git clone https://github.com/slackapi/bolt-python.git + +# Change into this project +cd bolt-python/examples/getting_started/ + +# Setup virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install the dependencies +pip install -r requirements.txt +``` + +### 3. Start servers + +[Setup ngrok][3] to create a local requests URL for development. + +```zsh +ngrok http 3000 +python3 app.py +``` + +[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/creating-an-app#setting-up-events diff --git a/examples/getting_started/app.py b/examples/getting_started/app.py new file mode 100644 index 000000000..aa5223d51 --- /dev/null +++ b/examples/getting_started/app.py @@ -0,0 +1,42 @@ +import os +from slack_bolt import App + +# Initializes your app with your bot token and signing secret +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), +) + + +# Listens to incoming messages that contain "hello" +# To learn available listener method arguments, +# visit https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html +@app.message("hello") +def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say( + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click", + }, + } + ], + text=f"Hey there <@{message['user']}>!", + ) + + +@app.action("button_click") +def action_button_click(body, ack, say): + # Acknowledge the action + ack() + say(f"<@{body['user']['id']}> clicked the button") + + +# Start your app +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) diff --git a/examples/getting_started/requirements.txt b/examples/getting_started/requirements.txt new file mode 100644 index 000000000..323ad5e29 --- /dev/null +++ b/examples/getting_started/requirements.txt @@ -0,0 +1 @@ +slack-bolt \ No newline at end of file diff --git a/examples/google_app_engine/flask/.gcloudignore b/examples/google_app_engine/flask/.gcloudignore new file mode 100644 index 000000000..a987f1123 --- /dev/null +++ b/examples/google_app_engine/flask/.gcloudignore @@ -0,0 +1,19 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Python pycache: +__pycache__/ +# Ignored by the build system +/setup.cfg \ No newline at end of file diff --git a/examples/google_app_engine/flask/.gitignore b/examples/google_app_engine/flask/.gitignore new file mode 100644 index 000000000..c9a0737e2 --- /dev/null +++ b/examples/google_app_engine/flask/.gitignore @@ -0,0 +1 @@ +env_variables.yaml \ No newline at end of file diff --git a/examples/google_app_engine/flask/app.yaml b/examples/google_app_engine/flask/app.yaml new file mode 100644 index 000000000..dfe7de530 --- /dev/null +++ b/examples/google_app_engine/flask/app.yaml @@ -0,0 +1,16 @@ +runtime: python38 + +inbound_services: + - warmup + +automatic_scaling: + min_idle_instances: 1 + + +handlers: + - url: /slack/events + secure: always + script: auto + +includes: + - env_variables.yaml diff --git a/examples/google_app_engine/flask/env_variables.yaml.sample b/examples/google_app_engine/flask/env_variables.yaml.sample new file mode 100644 index 000000000..da4c287e2 --- /dev/null +++ b/examples/google_app_engine/flask/env_variables.yaml.sample @@ -0,0 +1,3 @@ +env_variables: + SLACK_BOT_TOKEN: "xoxb-xxx" + SLACK_SIGNING_SECRET: "yyy" \ No newline at end of file diff --git a/examples/google_app_engine/flask/main.py b/examples/google_app_engine/flask/main.py new file mode 100644 index 000000000..6ed295aac --- /dev/null +++ b/examples/google_app_engine/flask/main.py @@ -0,0 +1,36 @@ +import logging +import os + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +bolt_app = App() + + +@bolt_app.command("/hey-google-app-engine") +def hello(body, ack): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +app = Flask(__name__) +handler = SlackRequestHandler(bolt_app) + + +@app.route("/_ah/warmup") +def warmup(): + # Handle your warmup logic here, e.g. set up a database connection pool + return "", 200, {} + + +@app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + + +# Only for local debug +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/examples/google_app_engine/flask/requirements.txt b/examples/google_app_engine/flask/requirements.txt new file mode 100644 index 000000000..6b56c96ae --- /dev/null +++ b/examples/google_app_engine/flask/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt +Flask>=1.1,<2 \ No newline at end of file diff --git a/examples/google_cloud_functions/.env.yaml.oauth-sample b/examples/google_cloud_functions/.env.yaml.oauth-sample new file mode 100644 index 000000000..75ff16f8b --- /dev/null +++ b/examples/google_cloud_functions/.env.yaml.oauth-sample @@ -0,0 +1,4 @@ +SLACK_CLIENT_ID: '1111.222' +SLACK_CLIENT_SECRET: 'xxx' +SLACK_SIGNING_SECRET: 'yyy' +SLACK_SCOPES: 'app_mentions:read,chat:write,commands' diff --git a/examples/google_cloud_functions/.env.yaml.sample b/examples/google_cloud_functions/.env.yaml.sample new file mode 100644 index 000000000..3a2d4c08f --- /dev/null +++ b/examples/google_cloud_functions/.env.yaml.sample @@ -0,0 +1,2 @@ +SLACK_SIGNING_SECRET: xxx +SLACK_BOT_TOKEN: xoxb-xxx \ No newline at end of file diff --git a/examples/google_cloud_functions/.gcloudignore b/examples/google_cloud_functions/.gcloudignore new file mode 100644 index 000000000..1451c7b19 --- /dev/null +++ b/examples/google_cloud_functions/.gcloudignore @@ -0,0 +1,18 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore +.env.yaml.sample + +node_modules +#!include:.gitignore diff --git a/examples/google_cloud_functions/.gitignore b/examples/google_cloud_functions/.gitignore new file mode 100644 index 000000000..312fe55e1 --- /dev/null +++ b/examples/google_cloud_functions/.gitignore @@ -0,0 +1,2 @@ +.env.yaml +main.py diff --git a/examples/google_cloud_functions/datastore.py b/examples/google_cloud_functions/datastore.py new file mode 100644 index 000000000..97d9d5877 --- /dev/null +++ b/examples/google_cloud_functions/datastore.py @@ -0,0 +1,244 @@ +# +# Please note that this is an example implementation. +# You can reuse this implementation for your app, +# but we don't have short-term plans to add this code to slack-sdk package. +# Please maintain the code on your own if you copy this file. +# +# Also, please refer to the following gist for more discussion and better implementation: +# https://gist.github.com/seratch/d81a445ef4467b16f047156bf859cda8 +# + +import logging +from logging import Logger +from typing import Optional +from uuid import uuid4 + +from google.cloud import datastore +from google.cloud.datastore import Client, Entity, Query +from slack_sdk.oauth import OAuthStateStore, InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot + + +class GoogleDatastoreInstallationStore(InstallationStore): + datastore_client: Client + + def __init__( + self, + *, + datastore_client: Client, + logger: Logger, + ): + self.datastore_client = datastore_client + self._logger = logger + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + def installation_key( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + suffix: Optional[str] = None, + is_enterprise_install: Optional[bool] = None, + ): + enterprise_id = enterprise_id or "none" + team_id = "none" if is_enterprise_install else team_id or "none" + name = f"{enterprise_id}-{team_id}-{user_id}" if user_id else f"{enterprise_id}-{team_id}" + if suffix is not None: + name += "-" + suffix + return self.datastore_client.key("installations", name) + + def bot_key( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + suffix: Optional[str] = None, + is_enterprise_install: Optional[bool] = None, + ): + enterprise_id = enterprise_id or "none" + team_id = "none" if is_enterprise_install else team_id or "none" + name = f"{enterprise_id}-{team_id}" + if suffix is not None: + name += "-" + suffix + return self.datastore_client.key("bots", name) + + def save(self, i: Installation): + # the latest installation in the workspace + installation_entity: Entity = datastore.Entity( + key=self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=None, # user_id is removed + is_enterprise_install=i.is_enterprise_install, + ) + ) + installation_entity.update(**i.to_dict()) + self.datastore_client.put(installation_entity) + + # the latest installation associated with a user + user_entity: Entity = datastore.Entity( + key=self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=i.user_id, + is_enterprise_install=i.is_enterprise_install, + ) + ) + user_entity.update(**i.to_dict()) + self.datastore_client.put(user_entity) + # history data + user_entity.key = self.installation_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + user_id=i.user_id, + is_enterprise_install=i.is_enterprise_install, + suffix=str(i.installed_at), + ) + self.datastore_client.put(user_entity) + + # the latest bot authorization in the workspace + bot = i.to_bot() + bot_entity: Entity = datastore.Entity( + key=self.bot_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + is_enterprise_install=i.is_enterprise_install, + ) + ) + bot_entity.update(**bot.to_dict()) + self.datastore_client.put(bot_entity) + # history data + bot_entity.key = self.bot_key( + enterprise_id=i.enterprise_id, + team_id=i.team_id, + is_enterprise_install=i.is_enterprise_install, + suffix=str(i.installed_at), + ) + self.datastore_client.put(bot_entity) + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + entity: Entity = self.datastore_client.get( + self.bot_key( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=is_enterprise_install, + ) + ) + if entity is not None: + entity["installed_at"] = entity["installed_at"].timestamp() + return Bot(**entity) + return None + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + entity: Entity = self.datastore_client.get( + self.installation_key( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=is_enterprise_install, + ) + ) + if entity is not None: + entity["installed_at"] = entity["installed_at"].timestamp() + return Installation(**entity) + return None + + def delete_installation( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + ) -> None: + installation_key = self.installation_key( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + ) + q: Query = self.datastore_client.query() + q.key_filter(installation_key, ">=") + for entity in q.fetch(): + if entity.key.name.startswith(installation_key.name): + self.datastore_client.delete(entity.key) + else: + break + + def delete_bot( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + ) -> None: + bot_key = self.bot_key( + enterprise_id=enterprise_id, + team_id=team_id, + ) + q: Query = self.datastore_client.query() + q.key_filter(bot_key, ">=") + for entity in q.fetch(): + if entity.key.name.startswith(bot_key.name): + self.datastore_client.delete(entity.key) + else: + break + + def delete_all( + self, + enterprise_id: Optional[str], + team_id: Optional[str], + ): + self.delete_bot(enterprise_id=enterprise_id, team_id=team_id) + self.delete_installation(enterprise_id=enterprise_id, team_id=team_id, user_id=None) + + +class GoogleDatastoreOAuthStateStore(OAuthStateStore): + logger: Logger + datastore_client: Client + collection_id: str + + def __init__( + self, + *, + datastore_client: Client, + logger: Logger, + ): + self.datastore_client = datastore_client + self._logger = logger + self.collection_id = "oauth_state_values" + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger + + def consume(self, state: str) -> bool: + key = self.datastore_client.key(self.collection_id, state) + entity = self.datastore_client.get(key) + if entity is not None: + self.datastore_client.delete(key) + return True + return False + + def issue(self, *args, **kwargs) -> str: + state_value = str(uuid4()) + entity: Entity = datastore.Entity(key=self.datastore_client.key(self.collection_id, state_value)) + entity.update(value=state_value) + self.datastore_client.put(entity) + return state_value diff --git a/examples/google_cloud_functions/oauth_main.py b/examples/google_cloud_functions/oauth_main.py new file mode 100644 index 000000000..773eafcf3 --- /dev/null +++ b/examples/google_cloud_functions/oauth_main.py @@ -0,0 +1,99 @@ +# https://cloud.google.com/functions/docs/first-python + +import logging + +from slack_bolt.oauth.oauth_settings import OAuthSettings + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App +from datastore import GoogleDatastoreInstallationStore, GoogleDatastoreOAuthStateStore + +from google.cloud import datastore + +datastore_client = datastore.Client() +logger = logging.getLogger(__name__) + +# process_before_response must be True when running on FaaS +app = App( + process_before_response=True, + installation_store=GoogleDatastoreInstallationStore( + datastore_client=datastore_client, + logger=logger, + ), + oauth_settings=OAuthSettings( + state_store=GoogleDatastoreOAuthStateStore( + datastore_client=datastore_client, + logger=logger, + ), + ), +) + + +@app.command("/hello-bolt-python-gcp") +def hello_command(ack): + ack("Hi from Google Cloud Functions!") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("Hi from Google Cloud Functions!") + + +# Flask adapter +from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from flask import Request + + +handler = SlackRequestHandler(app) + + +# Cloud Function +def hello_bolt_app(req: Request): + """HTTP Cloud Function. + Args: + req (flask.Request): The request object. + + Returns: + The response text, or any set of values that can be turned into a + Response object using `make_response` + . + """ + return handler.handle(req) + + +# For local development +# python main.py +if __name__ == "__main__": + from flask import Flask, request + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET", "POST"]) + def handle_anything(): + return handler.handle(request) + + flask_app.run(port=3000) + + +# Step1: Create a new Slack App: https://api.slack.com/apps +# Bot Token Scopes: app_mentions:read,chat:write,commands + +# Step2: Set env variables +# cp .env.yaml.oauth-sample .env.yaml +# vi .env.yaml + +# Step3: Create a new Google Cloud project +# gcloud projects create YOUR_PROJECT_NAME +# gcloud config set project YOUR_PROJECT_NAME + +# Step4: Deploy a function in the project +# cp oauth_main.py main.py +# gcloud functions deploy hello_bolt_app --runtime python38 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# gcloud functions describe hello_bolt_app + +# Step5: Set Request URL +# Set https://us-central1-YOUR_PROJECT_NAME.cloudfunctions.net/hello_bolt_app to the following: +# * slash command: /hello-bolt-python-gcp +# * Events Subscriptions & add `app_mention` event diff --git a/examples/google_cloud_functions/requirements.txt b/examples/google_cloud_functions/requirements.txt new file mode 100644 index 000000000..7ebbe8ab2 --- /dev/null +++ b/examples/google_cloud_functions/requirements.txt @@ -0,0 +1,3 @@ +Flask>1 +slack_bolt +google-cloud-datastore>=2.1.0,<3 \ No newline at end of file diff --git a/examples/google_cloud_functions/simple_main.py b/examples/google_cloud_functions/simple_main.py new file mode 100644 index 000000000..9ff839aca --- /dev/null +++ b/examples/google_cloud_functions/simple_main.py @@ -0,0 +1,79 @@ +# https://cloud.google.com/functions/docs/first-python + +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App + +# process_before_response must be True when running on FaaS +app = App(process_before_response=True) + + +@app.command("/hello-bolt-python-gcp") +def hello_command(ack): + ack("Hi from Google Cloud Functions!") + + +@app.event("app_mention") +def event_test(body, say, logger): + logger.info(body) + say("Hi from Google Cloud Functions!") + + +# Flask adapter +from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from flask import Request + + +handler = SlackRequestHandler(app) + + +# Cloud Function +def hello_bolt_app(req: Request): + """HTTP Cloud Function. + Args: + req (flask.Request): The request object. + + Returns: + The response text, or any set of values that can be turned into a + Response object using `make_response` + . + """ + return handler.handle(req) + + +# For local development +# python main.py +if __name__ == "__main__": + from flask import Flask, request + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET", "POST"]) + def handle_anything(): + return handler.handle(request) + + flask_app.run(port=3000) + + +# Step1: Create a new Slack App: https://api.slack.com/apps +# Bot Token Scopes: app_mentions:read,chat:write,commands + +# Step2: Set env variables +# cp .env.yaml.sample .env.yaml +# vi .env.yaml + +# Step3: Create a new Google Cloud project +# gcloud projects create YOUR_PROJECT_NAME +# gcloud config set project YOUR_PROJECT_NAME + +# Step4: Deploy a function in the project +# cp simple_main.py main.py +# gcloud functions deploy hello_bolt_app --runtime python39 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# gcloud functions describe hello_bolt_app + +# Step5: Set Request URL +# Set https://us-central1-YOUR_PROJECT_NAME.cloudfunctions.net/hello_bolt_app to the following: +# * slash command: /hello-bolt-python-gcp +# * Events Subscriptions & add `app_mention` event diff --git a/examples/google_cloud_run/aiohttp/Dockerfile b/examples/google_cloud_run/aiohttp/Dockerfile new file mode 100644 index 000000000..e4432e3a2 --- /dev/null +++ b/examples/google_cloud_run/aiohttp/Dockerfile @@ -0,0 +1,28 @@ +# +# https://cloud.google.com/run/docs/quickstarts/build-and-deploy +# +# export PROJECT_ID=`gcloud config get-value project` +# export SLACK_SIGNING_SECRET= +# export SLACK_BOT_TOKEN= +# gcloud builds submit --tag gcr.io/$PROJECT_ID/helloworld +# gcloud run deploy helloworld --image gcr.io/$PROJECT_ID/helloworld --platform managed --update-env-vars SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET,SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# + +# ---------------------------------------------- +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.8.5-slim-buster + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + +# Install production dependencies. +RUN pip install -U pip && pip install -r requirements.txt + +# Start AIOHTTP server +ENTRYPOINT python main.py diff --git a/examples/google_cloud_run/aiohttp/main.py b/examples/google_cloud_run/aiohttp/main.py new file mode 100644 index 000000000..41023c15e --- /dev/null +++ b/examples/google_cloud_run/aiohttp/main.py @@ -0,0 +1,17 @@ +import logging +import os + +from slack_bolt.async_app import AsyncApp + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +@app.command("/hey-google") +async def hello(body, ack): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) diff --git a/examples/google_cloud_run/aiohttp/requirements.txt b/examples/google_cloud_run/aiohttp/requirements.txt new file mode 100644 index 000000000..9ffe0006a --- /dev/null +++ b/examples/google_cloud_run/aiohttp/requirements.txt @@ -0,0 +1,2 @@ +slack_bolt +aiohttp>=3,<4 \ No newline at end of file diff --git a/examples/google_cloud_run/flask-gunicorn/Dockerfile b/examples/google_cloud_run/flask-gunicorn/Dockerfile new file mode 100644 index 000000000..8342e3a8b --- /dev/null +++ b/examples/google_cloud_run/flask-gunicorn/Dockerfile @@ -0,0 +1,28 @@ +# +# https://cloud.google.com/run/docs/quickstarts/build-and-deploy +# +# export PROJECT_ID=`gcloud config get-value project` +# export SLACK_SIGNING_SECRET= +# export SLACK_BOT_TOKEN= +# gcloud builds submit --tag gcr.io/$PROJECT_ID/helloworld +# gcloud run deploy helloworld --image gcr.io/$PROJECT_ID/helloworld --platform managed --update-env-vars SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET,SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# + +# ---------------------------------------------- +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.8.5-slim-buster + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + +# Install production dependencies. +RUN pip install -U pip && pip install -r requirements.txt + +# Run the web service on container startup. +ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app diff --git a/examples/google_cloud_run/flask-gunicorn/main.py b/examples/google_cloud_run/flask-gunicorn/main.py new file mode 100644 index 000000000..70bf437cb --- /dev/null +++ b/examples/google_cloud_run/flask-gunicorn/main.py @@ -0,0 +1,30 @@ +import logging +import os + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hey-google") +def hello(body, ack): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + + +# Only for local debug +if __name__ == "__main__": + flask_app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/examples/google_cloud_run/flask-gunicorn/requirements.txt b/examples/google_cloud_run/flask-gunicorn/requirements.txt new file mode 100644 index 000000000..24cc3e223 --- /dev/null +++ b/examples/google_cloud_run/flask-gunicorn/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>=1.1 +gunicorn>=20 \ No newline at end of file diff --git a/examples/google_cloud_run/sanic/Dockerfile b/examples/google_cloud_run/sanic/Dockerfile new file mode 100644 index 000000000..d71ddef67 --- /dev/null +++ b/examples/google_cloud_run/sanic/Dockerfile @@ -0,0 +1,28 @@ +# +# https://cloud.google.com/run/docs/quickstarts/build-and-deploy +# +# export PROJECT_ID=`gcloud config get-value project` +# export SLACK_SIGNING_SECRET= +# export SLACK_BOT_TOKEN= +# gcloud builds submit --tag gcr.io/$PROJECT_ID/helloworld +# gcloud run deploy helloworld --image gcr.io/$PROJECT_ID/helloworld --platform managed --update-env-vars SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET,SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# + +# ---------------------------------------------- +# Use the official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.8.5-slim-buster + +# Allow statements and log messages to immediately appear in the Knative logs +ENV PYTHONUNBUFFERED True + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY . ./ + +# Install production dependencies. +RUN pip install -U pip && pip install -r requirements.txt + +# Start Sanic server +ENTRYPOINT python main.py diff --git a/examples/google_cloud_run/sanic/main.py b/examples/google_cloud_run/sanic/main.py new file mode 100644 index 000000000..269cd8a04 --- /dev/null +++ b/examples/google_cloud_run/sanic/main.py @@ -0,0 +1,30 @@ +import logging +import os + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.command("/hey-google") +async def hello(body, ack): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +from sanic import Sanic +from sanic.request import Request + +api = Sanic(name="awesome-slack-app") + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) diff --git a/examples/google_cloud_run/sanic/requirements.txt b/examples/google_cloud_run/sanic/requirements.txt new file mode 100644 index 000000000..f72449c0e --- /dev/null +++ b/examples/google_cloud_run/sanic/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +aiohttp>=3,<4 +sanic>=20,<21 \ No newline at end of file diff --git a/examples/heroku/Procfile b/examples/heroku/Procfile new file mode 100644 index 000000000..3da739fbe --- /dev/null +++ b/examples/heroku/Procfile @@ -0,0 +1 @@ +web: gunicorn --bind :$PORT --workers 1 --threads 10 --timeout 0 main:flask_app diff --git a/examples/heroku/main.py b/examples/heroku/main.py new file mode 100644 index 000000000..91a123ab5 --- /dev/null +++ b/examples/heroku/main.py @@ -0,0 +1,38 @@ +import logging + +from slack_bolt import App + +logging.basicConfig(level=logging.DEBUG) +app = App() + + +@app.command("/hello-bolt-python-heroku") +def hello(body, ack): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +from flask import Flask, request +from slack_bolt.adapter.flask import SlackRequestHandler + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + + +# heroku login +# heroku create +# git remote add heroku https://git.heroku.com/xxx.git + +# export SLACK_BOT_TOKEN=xxx +# export SLACK_SIGNING_SECRET=xxx +# heroku config:set SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN +# heroku config:set SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET +# git checkout -b main +# git add . +# git commit -m'Initial commit for my awesome Slack app' +# git push heroku main diff --git a/examples/heroku/requirements.txt b/examples/heroku/requirements.txt new file mode 100644 index 000000000..24cc3e223 --- /dev/null +++ b/examples/heroku/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt +Flask>=1.1 +gunicorn>=20 \ No newline at end of file diff --git a/samples/async_modals_app.py b/examples/lazy_async_modals_app.py similarity index 70% rename from samples/async_modals_app.py rename to examples/lazy_async_modals_app.py index c4a65a777..663d0699a 100644 --- a/samples/async_modals_app.py +++ b/examples/lazy_async_modals_app.py @@ -1,31 +1,24 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt import asyncio -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging +from slack_bolt.async_app import AsyncApp logging.basicConfig(level=logging.DEBUG) -from slack_bolt.async_app import AsyncApp - app = AsyncApp() @app.middleware # or app.use(log_request) -async def log_request(logger, payload, next): - logger.debug(payload) +async def log_request(logger, body, next): + logger.debug(body) return await next() -@app.command("/hello-bolt-python") -async def handle_command(payload, ack, respond, client, logger): - logger.info(payload) - await ack("Accepted!") +async def ack_command(body, ack, logger): + logger.info(body) + await ack("Thanks!") + +async def post_button_message(respond): await respond( blocks=[ { @@ -45,19 +38,33 @@ async def handle_command(payload, ack, respond, client, logger): ] ) + +async def open_modal(body, client, logger): res = await client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -66,6 +73,7 @@ async def handle_command(payload, ack, respond, client, logger): "type": "external_select", "action_id": "es_a", "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, }, "label": {"type": "plain_text", "text": "Search"}, }, @@ -76,6 +84,7 @@ async def handle_command(payload, ack, respond, client, logger): "type": "multi_external_select", "action_id": "mes_a", "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, }, "label": {"type": "plain_text", "text": "Search (multi)"}, }, @@ -85,11 +94,15 @@ async def handle_command(payload, ack, respond, client, logger): logger.info(res) +app.command("/hello-bolt-python")( + ack=ack_command, + lazy=[post_button_message, open_modal], +) + + @app.options("es_a") async def show_options(ack): - await ack( - {"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]} - ) + await ack({"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}) @app.options("mes_a") @@ -125,19 +138,22 @@ async def show_multi_options(ack): @app.view("view-id") -async def view_submission(ack, payload, logger): +async def handle_view_submission(ack, body, logger): await ack() - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) -@app.action("a") -async def button_click(ack, respond): +async def ack_button_click(ack, respond): await ack() + await respond("Loading ...") + + +async def respond_5_seconds_later(respond): await asyncio.sleep(5) - await respond( - {"response_type": "in_channel", "text": "Clicked!",} - ) + await respond("Completed!") + +app.action("a")(ack=ack_button_click, lazy=[respond_5_seconds_later]) if __name__ == "__main__": app.start(3000) diff --git a/examples/lazy_modals_app.py b/examples/lazy_modals_app.py new file mode 100644 index 000000000..f4f22d5e9 --- /dev/null +++ b/examples/lazy_modals_app.py @@ -0,0 +1,165 @@ +import logging +import time + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App + +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +def ack_command(body, ack, logger): + logger.info(body) + ack("Thanks!") + + +def post_button_message(respond): + respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +def open_modal(body, client, logger): + res = client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + }, + { + "type": "input", + "block_id": "es_b", + "element": { + "type": "external_select", + "action_id": "es_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, + }, + "label": {"type": "plain_text", "text": "Search"}, + }, + { + "type": "input", + "block_id": "mes_b", + "element": { + "type": "multi_external_select", + "action_id": "mes_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + "min_query_length": 0, + }, + "label": {"type": "plain_text", "text": "Search (multi)"}, + }, + ], + }, + ) + logger.info(res) + + +app.command("/hello-bolt-python")( + ack=ack_command, + lazy=[post_button_message, open_modal], +) + + +@app.options("es_a") +def show_options(ack): + ack({"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}) + + +@app.options("mes_a") +def show_multi_options(ack): + ack( + { + "option_groups": [ + { + "label": {"type": "plain_text", "text": "Group 1"}, + "options": [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ], + }, + { + "label": {"type": "plain_text", "text": "Group 2"}, + "options": [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "2-1", + }, + ], + }, + ] + } + ) + + +@app.view("view-id") +def handle_view_submission(ack, body, logger): + ack() + logger.info(body["view"]["state"]["values"]) + + +def ack_button_click(ack, respond): + ack() + respond("Loading ...") + + +def respond_5_seconds_later(respond): + time.sleep(5) + respond("Completed!") + + +app.action("a")(ack=ack_button_click, lazy=[respond_5_seconds_later]) + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python modals_app.py diff --git a/examples/message_events.py b/examples/message_events.py new file mode 100644 index 000000000..3fd424060 --- /dev/null +++ b/examples/message_events.py @@ -0,0 +1,90 @@ +import logging +import re +from typing import Callable + +from slack_bolt import App, Say, BoltContext +from slack_sdk import WebClient + +logging.basicConfig(level=logging.DEBUG) + +app = App() + + +@app.middleware +def log_request(logger: logging.Logger, body: dict, next: Callable): + logger.debug(body) + return next() + + +@app.message("test") +def reply_to_test(say): + say("Yes, tests are important!") + + +@app.message(re.compile("bug")) +def mention_bug(say): + say("Do you mind filing a ticket?") + + +# middleware function +def extract_subtype(body: dict, context: BoltContext, next: Callable): + context["subtype"] = body.get("event", {}).get("subtype", None) + next() + + +# https://docs.slack.dev/reference/events/message/ +# Newly posted messages only +# or @app.event("message") +@app.event({"type": "message", "subtype": None}) +def reply_in_thread(body: dict, say: Say): + event = body["event"] + thread_ts = event.get("thread_ts", None) or event["ts"] + say(text="Hey, what's up?", thread_ts=thread_ts) + + +@app.event( + event={"type": "message", "subtype": "message_deleted"}, + matchers=[ + # Skip the deletion of messages by this listener + lambda body: "You've deleted a message: " + not in body["event"]["previous_message"]["text"] + ], +) +def detect_deletion(say: Say, body: dict): + text = body["event"]["previous_message"]["text"] + say(f"You've deleted a message: {text}") + + +# https://docs.slack.dev/reference/events/message/file_share +# https://docs.slack.dev/reference/events/message/bot_message +@app.event( + event={"type": "message", "subtype": re.compile("(me_message)|(file_share)")}, + middleware=[extract_subtype], +) +def add_reaction(body: dict, client: WebClient, context: BoltContext, logger: logging.Logger): + subtype = context["subtype"] # by extract_subtype + logger.info(f"subtype: {subtype}") + message_ts = body["event"]["ts"] + api_response = client.reactions_add( + channel=context.channel_id, + timestamp=message_ts, + name="eyes", + ) + logger.info(f"api_response: {api_response}") + + +# This listener handles all uncaught message events +# (The position in source code matters) +@app.event({"type": "message"}, middleware=[extract_subtype]) +def just_ack(logger, context): + subtype = context["subtype"] # by extract_subtype + logger.info(f"{subtype} is ignored") + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python message_events.py diff --git a/samples/modals_app.py b/examples/modals_app.py similarity index 76% rename from samples/modals_app.py rename to examples/modals_app.py index 6a535273b..c38f69e7c 100644 --- a/samples/modals_app.py +++ b/examples/modals_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -15,21 +8,24 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) +def log_request(logger, body, next): + logger.debug(body) return next() @app.command("/hello-bolt-python") -def handle_command(payload, ack, respond, client, logger): - logger.info(payload) +def handle_command(body, ack, respond, client, logger): + logger.info(body) ack( text="Accepted!", blocks=[ { "type": "section", "block_id": "b", - "text": {"type": "mrkdwn", "text": ":white_check_mark: Accepted!",}, + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: Accepted!", + }, } ], ) @@ -54,18 +50,30 @@ def handle_command(payload, ack, respond, client, logger): ) res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -97,9 +105,7 @@ def handle_command(payload, ack, respond, client, logger): @app.options("es_a") def show_options(ack): - ack( - {"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]} - ) + ack({"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}) @app.options("mes_a") @@ -135,16 +141,16 @@ def show_multi_options(ack): @app.view("view-id") -def view_submission(ack, payload, logger): +def view_submission(ack, body, logger): ack() - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) @app.action("a") -def button_click(ack, payload, respond): +def button_click(ack, body, respond): ack() - user_id = payload["user"]["id"] + user_id = body["user"]["id"] # in_channel / dict respond( { @@ -155,7 +161,8 @@ def button_click(ack, payload, respond): ) # ephemeral / kwargs respond( - replace_original=False, text=":white_check_mark: Done!", + replace_original=False, + text=":white_check_mark: Done!", ) diff --git a/samples/modals_app_typed.py b/examples/modals_app_typed.py similarity index 75% rename from samples/modals_app_typed.py rename to examples/modals_app_typed.py index 021a5e4f1..b8b709721 100644 --- a/samples/modals_app_typed.py +++ b/examples/modals_app_typed.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from typing import Callable from logging import Logger @@ -30,18 +23,14 @@ @app.middleware # or app.use(log_request) -def log_request( - logger: Logger, payload: dict, next: Callable[[], BoltResponse] -) -> BoltResponse: - logger.debug(payload) +def log_request(logger: Logger, body: dict, next: Callable[[], BoltResponse]) -> BoltResponse: + logger.debug(body) return next() @app.command("/hello-bolt-python") -def handle_command( - payload: dict, ack: Ack, respond: Respond, client: WebClient, logger: Logger -) -> None: - logger.info(payload) +def handle_command(body: dict, ack: Ack, respond: Respond, client: WebClient, logger: Logger) -> None: + logger.info(body) ack( text="Accepted!", blocks=[ @@ -56,18 +45,18 @@ def handle_command( blocks=[ SectionBlock( block_id="b", - text=MarkdownTextObject( - text="You can add a button alongside text in your message. " - ), + text=MarkdownTextObject(text="You can add a button alongside text in your message. "), accessory=ButtonElement( - action_id="a", text=PlainTextObject(text="Button"), value="click_me_123", + action_id="a", + text=PlainTextObject(text="Button"), + value="click_me_123", ), ), ] ) res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view=View( type="modal", callback_id="view-id", @@ -121,23 +110,25 @@ def show_multi_options(ack: Ack) -> None: ), OptionGroup( label=PlainTextObject(text="Group 2"), - options=[Option(text=PlainTextObject(text="Option 1"), value="2-1"),], + options=[ + Option(text=PlainTextObject(text="Option 1"), value="2-1"), + ], ), ] ) @app.view("view-id") -def view_submission(ack: Ack, payload: dict, logger: Logger) -> None: +def view_submission(ack: Ack, body: dict, logger: Logger) -> None: ack() - logger.info(payload["view"]["state"]["values"]) + logger.info(body["view"]["state"]["values"]) @app.action("a") -def button_click(ack: Ack, payload: dict, respond: Respond) -> None: +def button_click(ack: Ack, body: dict, respond: Respond) -> None: ack() - user_id: str = payload["user"]["id"] + user_id: str = body["user"]["id"] # in_channel / dict respond( response_type="in_channel", @@ -146,7 +137,8 @@ def button_click(ack: Ack, payload: dict, respond: Respond) -> None: ) # ephemeral / kwargs respond( - replace_original=False, text=":white_check_mark: Done!", + replace_original=False, + text=":white_check_mark: Done!", ) diff --git a/samples/oauth_app.py b/examples/oauth_app.py similarity index 64% rename from samples/oauth_app.py rename to examples/oauth_app.py index 120c689c3..ac05bde2e 100644 --- a/samples/oauth_app.py +++ b/examples/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from slack_bolt import App @@ -13,15 +6,15 @@ @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") @app.command("/hello-bolt-python") # or app.command(re.compile(r"/hello-.+"))(test_command) -def test_command(payload, respond, client, ack, logger): - logger.info(payload) +def test_command(body, respond, client, ack, logger): + logger.info(body) ack("Thanks!") respond( @@ -44,18 +37,30 @@ def test_command(payload, respond, client, ack, logger): ) res = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, @@ -64,14 +69,14 @@ def test_command(payload, respond, client, ack, logger): @app.view("view-id") -def view_submission(ack, payload, logger): - logger.info(payload) +def view_submission(ack, body, logger): + logger.info(body) return ack() @app.action("a") -def button_click(logger, payload, ack, respond): - logger.info(payload) +def button_click(logger, body, ack, respond): + logger.info(body) respond("respond!") ack() @@ -81,7 +86,6 @@ def button_click(logger, payload, ack, respond): # pip install slack_bolt # export SLACK_SIGNING_SECRET=*** -# export SLACK_BOT_TOKEN=xoxb-*** # export SLACK_CLIENT_ID=111.111 # export SLACK_CLIENT_SECRET=*** # export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write diff --git a/examples/oauth_app_settings.py b/examples/oauth_app_settings.py new file mode 100644 index 000000000..1877b07fa --- /dev/null +++ b/examples/oauth_app_settings.py @@ -0,0 +1,117 @@ +import logging +import os +from slack_bolt import App, BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings + +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +logging.basicConfig(level=logging.DEBUG) + + +def success(args: SuccessArgs) -> BoltResponse: + # Do anything here ... + # Call the default handler to return HTTP response + return args.default.success(args) + # return BoltResponse(status=200, body="Thanks!") + + +def failure(args: FailureArgs) -> BoltResponse: + return BoltResponse(status=args.suggested_status_code, body=args.reason) + + +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + installation_store=FileInstallationStore(), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["app_mentions:read", "channels:history", "im:history", "chat:write"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600), + callback_options=CallbackOptions(success=success, failure=failure), + ), +) + + +@app.command("/hello-bolt-python") +def test_command(body, respond, client, ack, logger): + logger.info(body) + ack("Thanks!") + + respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + res = client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +def view_submission(ack, body, logger): + logger.info(body) + return ack() + + +@app.action("a") +def button_click(logger, body, ack, respond): + logger.info(body) + respond("respond!") + ack() + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# python oauth_app.py diff --git a/samples/oauth_sqlite3_app.py b/examples/oauth_sqlite3_app.py similarity index 59% rename from samples/oauth_sqlite3_app.py rename to examples/oauth_sqlite3_app.py index 1cda11d94..2647de531 100644 --- a/samples/oauth_sqlite3_app.py +++ b/examples/oauth_sqlite3_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -12,15 +5,26 @@ from slack_bolt import App from slack_bolt.oauth import OAuthFlow -app = App(oauth_flow=OAuthFlow.sqlite3(database="./slackapp.db")) +app = App( + oauth_flow=OAuthFlow.sqlite3( + database="./slackapp.db", + token_rotation_expiration_minutes=60 * 24, # for testing + ) +) @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") +@app.command("/token-rotation-modal") +def handle_some_command(ack, body, logger): + ack() + logger.info(body) + + if __name__ == "__main__": app.start(3000) diff --git a/examples/oauth_sqlite3_app_bot_only.py b/examples/oauth_sqlite3_app_bot_only.py new file mode 100644 index 000000000..aa7296a2d --- /dev/null +++ b/examples/oauth_sqlite3_app_bot_only.py @@ -0,0 +1,38 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App +from slack_bolt.oauth import OAuthFlow + +app = App( + oauth_flow=OAuthFlow.sqlite3( + database="./slackapp.db", + token_rotation_expiration_minutes=60 * 24, # for testing + ), + installation_store_bot_only=True, +) + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +@app.command("/token-rotation-modal") +def handle_some_command(ack, body, logger): + ack() + logger.info(body) + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write +# python oauth_app.py diff --git a/examples/oauth_sqlite3_app_org_level.py b/examples/oauth_sqlite3_app_org_level.py new file mode 100644 index 000000000..d5dad1f26 --- /dev/null +++ b/examples/oauth_sqlite3_app_org_level.py @@ -0,0 +1,61 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App, BoltContext +from slack_bolt.oauth import OAuthFlow +from slack_sdk import WebClient + + +app = App(oauth_flow=OAuthFlow.sqlite3(database="./slackapp.db")) + + +@app.use +def dump(context, next, logger): + logger.info(context) + next() + + +@app.use +def call_apis_with_team_id(context: BoltContext, client: WebClient, next): + # client.users_list() + client.bots_info(bot=context.bot_id) + next() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +@app.command("/org-level-command") +def command(ack): + ack("I got it!") + + +@app.shortcut("org-level-shortcut") +def shortcut(ack): + ack() + + +@app.event("team_access_granted") +def team_access_granted(event): + pass + + +@app.event("team_access_revoked") +def team_access_revoked(event): + pass + + +if __name__ == "__main__": + app.start(3000) + +# pip install slack_bolt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write +# python oauth_app.py diff --git a/samples/pyramid/app.py b/examples/pyramid/app.py similarity index 65% rename from samples/pyramid/app.py rename to examples/pyramid/app.py index 4c37a3544..619563453 100644 --- a/samples/pyramid/app.py +++ b/examples/pyramid/app.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - - import logging from slack_bolt import App from slack_bolt.adapter.pyramid.handler import SlackRequestHandler @@ -15,8 +7,8 @@ @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") @@ -28,9 +20,7 @@ def event_test(payload, say, logger): with Configurator() as config: config.add_route("slack_events", "/slack/events") - config.add_view( - handler.handle, route_name="slack_events", request_method="POST" - ) + config.add_view(handler.handle, route_name="slack_events", request_method="POST") pyramid_app = config.make_wsgi_app() server = make_server("0.0.0.0", 3000, pyramid_app) server.serve_forever() diff --git a/samples/pyramid/oauth_app.py b/examples/pyramid/oauth_app.py similarity index 62% rename from samples/pyramid/oauth_app.py rename to examples/pyramid/oauth_app.py index 3306c4d61..912b7a04b 100644 --- a/samples/pyramid/oauth_app.py +++ b/examples/pyramid/oauth_app.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - - import logging from slack_bolt import App from slack_bolt.adapter.pyramid.handler import SlackRequestHandler @@ -15,8 +7,8 @@ @app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") @@ -28,18 +20,12 @@ def event_test(payload, say, logger): with Configurator() as config: config.add_route("slack_events", "/slack/events") - config.add_view( - handler.handle, route_name="slack_events", request_method="POST" - ) + config.add_view(handler.handle, route_name="slack_events", request_method="POST") config.add_route("slack_install", "/slack/install") config.add_route("slack_oauth_redirect", "/slack/oauth_redirect") - config.add_view( - handler.handle, route_name="slack_install", request_method="GET" - ) - config.add_view( - handler.handle, route_name="slack_oauth_redirect", request_method="GET" - ) + config.add_view(handler.handle, route_name="slack_install", request_method="GET") + config.add_view(handler.handle, route_name="slack_oauth_redirect", request_method="GET") pyramid_app = config.make_wsgi_app() server = make_server("0.0.0.0", 3000, pyramid_app) diff --git a/samples/pyramid/requirements.txt b/examples/pyramid/requirements.txt similarity index 100% rename from samples/pyramid/requirements.txt rename to examples/pyramid/requirements.txt diff --git a/samples/readme_app.py b/examples/readme_app.py similarity index 71% rename from samples/readme_app.py rename to examples/readme_app.py index d9de6becd..fe81a0904 100644 --- a/samples/readme_app.py +++ b/examples/readme_app.py @@ -11,26 +11,26 @@ # Middleware @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.info(payload) +def log_request(logger, body, next): + logger.info(body) return next() -# Events API: https://api.slack.com/events-api +# Events API: https://docs.slack.dev/apis/events-api/ @app.event("app_mention") def event_test(say): say("What's up?") -# Interactivity: https://api.slack.com/interactivity +# Interactivity: https://docs.slack.dev/interactivity/ @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") -def open_modal(ack, client, logger, payload): +def open_modal(ack, client, logger, body): # acknowledge the incoming request from Slack immediately ack() # open a modal api_response = client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -46,25 +46,23 @@ def open_modal(ack, client, logger, payload): { "type": "input", "block_id": "b", - "element": { - "type": "plain_text_input", - "action_id": "a" - }, + "element": {"type": "plain_text_input", "action_id": "a"}, "label": { "type": "plain_text", "text": "Label", - } + }, } - ] - }) + ], + }, + ) logger.debug(api_response) @app.view("view-id") -def view_submission(ack, payload, logger): +def view_submission(ack, view, logger): ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(payload["view"]["state"]["values"]) + logger.info(view["state"]["values"]) if __name__ == "__main__": diff --git a/samples/readme_async_app.py b/examples/readme_async_app.py similarity index 68% rename from samples/readme_async_app.py rename to examples/readme_async_app.py index 937b61022..f11d308a0 100644 --- a/samples/readme_async_app.py +++ b/examples/readme_async_app.py @@ -1,4 +1,5 @@ import logging + # requires `pip install "aiohttp>=3,<4"` from slack_bolt.async_app import AsyncApp @@ -9,28 +10,39 @@ app = AsyncApp() +from slack_bolt.async_app import AsyncApp + +app = AsyncApp() + + +@app.command("/hello-bolt-python") +async def command(ack, body, respond): + await ack() + await respond(f"Hi <@{body['user_id']}>!") + + # Middleware @app.middleware # or app.use(log_request) -async def log_request(logger, payload, next): - logger.info(payload) +async def log_request(logger, body, next): + logger.info(body) return await next() -# Events API: https://api.slack.com/events-api +# Events API: https://docs.slack.dev/apis/events-api/ @app.event("app_mention") async def event_test(say): await say("What's up?") -# Interactivity: https://api.slack.com/interactivity +# Interactivity: https://docs.slack.dev/interactivity/ @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") -async def open_modal(ack, client, logger, payload): +async def open_modal(ack, client, logger, body): # acknowledge the incoming request from Slack immediately await ack() # open a modal api_response = await client.views_open( - trigger_id=payload["trigger_id"], + trigger_id=body["trigger_id"], view={ "type": "modal", "callback_id": "view-id", @@ -46,25 +58,23 @@ async def open_modal(ack, client, logger, payload): { "type": "input", "block_id": "b", - "element": { - "type": "plain_text_input", - "action_id": "a" - }, + "element": {"type": "plain_text_input", "action_id": "a"}, "label": { "type": "plain_text", "text": "Label", - } + }, } - ] - }) + ], + }, + ) logger.debug(api_response) @app.view("view-id") -async def view_submission(ack, payload, logger): +async def view_submission(ack, view, logger): await ack() # Prints {'b': {'a': {'type': 'plain_text_input', 'value': 'Your Input'}}} - logger.info(payload["view"]["state"]["values"]) + logger.info(view["state"]["values"]) if __name__ == "__main__": diff --git a/samples/sanic/async_app.py b/examples/sanic/async_app.py similarity index 66% rename from samples/sanic/async_app.py rename to examples/sanic/async_app.py index 02b0b20cf..c10643640 100644 --- a/samples/sanic/async_app.py +++ b/examples/sanic/async_app.py @@ -1,10 +1,4 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - +import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler @@ -13,15 +7,15 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") from sanic import Sanic from sanic.request import Request -api = Sanic() +api = Sanic(name="awesome-slack-app") @api.post("/slack/events") @@ -29,6 +23,10 @@ async def endpoint(req: Request): return await app_handler.handle(req) +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** diff --git a/samples/sanic/async_oauth_app.py b/examples/sanic/async_oauth_app.py similarity index 76% rename from samples/sanic/async_oauth_app.py rename to examples/sanic/async_oauth_app.py index dc6205732..c65c085c7 100644 --- a/samples/sanic/async_oauth_app.py +++ b/examples/sanic/async_oauth_app.py @@ -1,10 +1,4 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - +import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler @@ -13,15 +7,15 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") from sanic import Sanic from sanic.request import Request -api = Sanic() +api = Sanic(name="awesome-slack-app") @api.post("/slack/events") @@ -39,6 +33,10 @@ async def oauth_redirect(req: Request): return await app_handler.handle(req) +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + # pip install -r requirements.txt # # -- OAuth flow -- # diff --git a/samples/sanic/requirements.txt b/examples/sanic/requirements.txt similarity index 100% rename from samples/sanic/requirements.txt rename to examples/sanic/requirements.txt diff --git a/examples/socket_mode.py b/examples/socket_mode.py new file mode 100644 index 000000000..bef5a6a95 --- /dev/null +++ b/examples/socket_mode.py @@ -0,0 +1,119 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Install the Slack app and get xoxb- token in advance +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + + +@app.command("/hello-socket-mode") +def hello_command(ack, body): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +def ack_shortcut(ack): + ack() + + +def open_modal(body, client): + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "socket_modal_submission", + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "title": { + "type": "plain_text", + "text": "Socket Modal", + }, + "blocks": [ + { + "type": "input", + "block_id": "q1", + "label": { + "type": "plain_text", + "text": "Write anything here!", + }, + "element": { + "action_id": "feedback", + "type": "plain_text_input", + }, + }, + { + "type": "input", + "block_id": "q2", + "label": { + "type": "plain_text", + "text": "Can you tell us your favorites?", + }, + "element": { + "type": "external_select", + "action_id": "favorite-animal", + "min_query_length": 0, + "placeholder": { + "type": "plain_text", + "text": "Select your favorites", + }, + }, + }, + ], + }, + ) + + +app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) + + +all_options = [ + { + "text": {"type": "plain_text", "text": ":cat: Cat"}, + "value": "cat", + }, + { + "text": {"type": "plain_text", "text": ":dog: Dog"}, + "value": "dog", + }, + { + "text": {"type": "plain_text", "text": ":bear: Bear"}, + "value": "bear", + }, +] + + +@app.options("favorite-animal") +def external_data_source_handler(ack, body): + keyword = body.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in all_options if keyword in o["text"]["text"]] + ack(options=options) + else: + ack(options=all_options) + + +@app.view("socket_modal_submission") +def submission(ack): + ack() + + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() diff --git a/examples/socket_mode_async.py b/examples/socket_mode_async.py new file mode 100644 index 000000000..d252fbe54 --- /dev/null +++ b/examples/socket_mode_async.py @@ -0,0 +1,128 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +# Install the Slack app and get xoxb- token in advance +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + + +@app.command("/hello-socket-mode") +async def hello_command(ack, body): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +async def event_test(event, say): + await say(f"Hi there, <@{event['user']}>!") + + +async def ack_shortcut(ack): + await ack() + + +async def open_modal(body, client): + await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "socket_modal_submission", + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "title": { + "type": "plain_text", + "text": "Socket Modal", + }, + "blocks": [ + { + "type": "input", + "block_id": "q1", + "label": { + "type": "plain_text", + "text": "Write anything here!", + }, + "element": { + "action_id": "feedback", + "type": "plain_text_input", + }, + }, + { + "type": "input", + "block_id": "q2", + "label": { + "type": "plain_text", + "text": "Can you tell us your favorites?", + }, + "element": { + "type": "external_select", + "action_id": "favorite-animal", + "min_query_length": 0, + "placeholder": { + "type": "plain_text", + "text": "Select your favorites", + }, + }, + }, + ], + }, + ) + + +app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) + + +all_options = [ + { + "text": {"type": "plain_text", "text": ":cat: Cat"}, + "value": "cat", + }, + { + "text": {"type": "plain_text", "text": ":dog: Dog"}, + "value": "dog", + }, + { + "text": {"type": "plain_text", "text": ":bear: Bear"}, + "value": "bear", + }, +] + + +@app.options("favorite-animal") +async def external_data_source_handler(ack, body): + keyword = body.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in all_options if keyword in o["text"]["text"]] + await ack(options=options) + else: + await ack(options=all_options) + + +@app.view("socket_modal_submission") +async def submission(ack): + await ack() + + +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/socket_mode_async_healthcheck.py b/examples/socket_mode_async_healthcheck.py new file mode 100644 index 000000000..2f183a462 --- /dev/null +++ b/examples/socket_mode_async_healthcheck.py @@ -0,0 +1,60 @@ +import logging +import os +from typing import Optional + +from slack_sdk.socket_mode.aiohttp import SocketModeClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +logging.basicConfig(level=logging.DEBUG) + +# +# Socket Mode Bolt app +# + +# Install the Slack app and get xoxb- token in advance +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) +socket_mode_client: Optional[SocketModeClient] = None + + +@app.event("app_mention") +async def event_test(event, say): + await say(f"Hi there, <@{event['user']}>!") + + +# +# Web app for hosting the healthcheck endpoint for k8s etc. +# + +from aiohttp import web + + +async def healthcheck(_req: web.Request): + if socket_mode_client is not None and socket_mode_client.is_connected(): + return web.Response(status=200, text="OK") + return web.Response(status=503, text="The Socket Mode client is inactive") + + +web_app = app.web_app() +web_app.add_routes([web.get("/health", healthcheck)]) + + +# +# Start the app +# + +if __name__ == "__main__": + + async def start_socket_mode(_web_app: web.Application): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.connect_async() + global socket_mode_client + socket_mode_client = handler.client + + async def shutdown_socket_mode(_web_app: web.Application): + await socket_mode_client.close() + + web_app.on_startup.append(start_socket_mode) + web_app.on_shutdown.append(shutdown_socket_mode) + web.run_app(app=web_app, port=8080) diff --git a/examples/socket_mode_healthcheck.py b/examples/socket_mode_healthcheck.py new file mode 100644 index 000000000..4b700c565 --- /dev/null +++ b/examples/socket_mode_healthcheck.py @@ -0,0 +1,47 @@ +import logging +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +logging.basicConfig(level=logging.DEBUG) + +# +# Socket Mode Bolt app +# + +# Install the Slack app and get xoxb- token in advance +app = App(token=os.environ["SLACK_BOT_TOKEN"]) +socket_mode_handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +# +# Web app for hosting the healthcheck endpoint for k8s etc. +# + +# pip install Flask +from flask import Flask, make_response + +flask_app = Flask(__name__) + + +@flask_app.route("/health", methods=["GET"]) +def slack_events(): + if socket_mode_handler.client is not None and socket_mode_handler.client.is_connected(): + return make_response("OK", 200) + return make_response("The Socket Mode client is inactive", 503) + + +# +# Start the app +# +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** + +if __name__ == "__main__": + socket_mode_handler.connect() # does not block the current thread + flask_app.run(port=8080) diff --git a/examples/socket_mode_oauth.py b/examples/socket_mode_oauth.py new file mode 100644 index 000000000..5e1816de3 --- /dev/null +++ b/examples/socket_mode_oauth.py @@ -0,0 +1,133 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_bolt.adapter.socket_mode import SocketModeHandler + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + oauth_settings=OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + scopes=os.environ["SLACK_SCOPES"].split(","), + ), +) + + +@app.command("/hello-socket-mode") +def hello_command(ack, body): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +def ack_shortcut(ack): + ack() + + +def open_modal(body, client): + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "socket_modal_submission", + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "title": { + "type": "plain_text", + "text": "Socket Modal", + }, + "blocks": [ + { + "type": "input", + "block_id": "q1", + "label": { + "type": "plain_text", + "text": "Write anything here!", + }, + "element": { + "action_id": "feedback", + "type": "plain_text_input", + }, + }, + { + "type": "input", + "block_id": "q2", + "label": { + "type": "plain_text", + "text": "Can you tell us your favorites?", + }, + "element": { + "type": "external_select", + "action_id": "favorite-animal", + "min_query_length": 0, + "placeholder": { + "type": "plain_text", + "text": "Select your favorites", + }, + }, + }, + ], + }, + ) + + +app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) + + +all_options = [ + { + "text": {"type": "plain_text", "text": ":cat: Cat"}, + "value": "cat", + }, + { + "text": {"type": "plain_text", "text": ":dog: Dog"}, + "value": "dog", + }, + { + "text": {"type": "plain_text", "text": ":bear: Bear"}, + "value": "bear", + }, +] + + +@app.options("favorite-animal") +def external_data_source_handler(ack, body): + keyword = body.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in all_options if keyword in o["text"]["text"]] + ack(options=options) + else: + ack(options=all_options) + + +@app.view("socket_modal_submission") +def submission(ack): + ack() + + +if __name__ == "__main__": + SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).connect() + app.start() + + # export SLACK_APP_TOKEN= + # export SLACK_SIGNING_SECRET= + # export SLACK_CLIENT_ID= + # export SLACK_CLIENT_SECRET= + # export SLACK_SCOPES= + # pip install .[optional] + # pip install slack_bolt + # python socket_mode_oauth.py diff --git a/examples/socket_mode_proxy.py b/examples/socket_mode_proxy.py new file mode 100644 index 000000000..e1e802957 --- /dev/null +++ b/examples/socket_mode_proxy.py @@ -0,0 +1,31 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os + +from slack_sdk import WebClient +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# pip3 install proxy.py +# proxy --port 9000 --log-level d +proxy_url = "http://localhost:9000" + +# Install the Slack app and get xoxb- token in advance +app = App(client=WebClient(token=os.environ["SLACK_BOT_TOKEN"], proxy=proxy_url)) + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + SocketModeHandler( + app=app, + app_token=os.environ["SLACK_APP_TOKEN"], + proxy=proxy_url, + ).start() diff --git a/examples/sqlalchemy/async_oauth_app.py b/examples/sqlalchemy/async_oauth_app.py new file mode 100644 index 000000000..39f9417ce --- /dev/null +++ b/examples/sqlalchemy/async_oauth_app.py @@ -0,0 +1,240 @@ +import logging +import os +import time +from datetime import datetime +from logging import Logger +from typing import Optional +from uuid import uuid4 + +import sqlalchemy +from databases import Database +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.oauth.installation_store.sqlalchemy import SQLAlchemyInstallationStore +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore +from slack_sdk.oauth.state_store.sqlalchemy import SQLAlchemyOAuthStateStore +from sqlalchemy import and_, desc, Table, MetaData + +from slack_bolt.adapter.sanic import AsyncSlackRequestHandler +from slack_bolt.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings + + +class AsyncSQLAlchemyInstallationStore(AsyncInstallationStore): + database_url: str + client_id: str + metadata: MetaData + installations: Table + bots: Table + + def __init__( + self, + client_id: str, + database_url: str, + logger: Logger = logging.getLogger(__name__), + ): + self.client_id = client_id + self.database_url = database_url + self._logger = logger + self.metadata = MetaData() + self.installations = SQLAlchemyInstallationStore.build_installations_table( + metadata=self.metadata, + table_name=SQLAlchemyInstallationStore.default_installations_table_name, + ) + self.bots = SQLAlchemyInstallationStore.build_bots_table( + metadata=self.metadata, + table_name=SQLAlchemyInstallationStore.default_bots_table_name, + ) + + @property + def logger(self) -> Logger: + return self._logger + + async def async_save(self, installation: Installation): + async with Database(self.database_url) as database: + async with database.transaction(): + i = installation.to_dict() + i["client_id"] = self.client_id + await database.execute(self.installations.insert(), i) + b = installation.to_bot().to_dict() + b["client_id"] = self.client_id + await database.execute(self.bots.insert(), b) + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + c = self.bots.c + query = ( + self.bots.select() + .where( + and_( + c.enterprise_id == enterprise_id, + c.team_id == team_id, + c.is_enterprise_install == is_enterprise_install, + ) + ) + .order_by(desc(c.installed_at)) + .limit(1) + ) + async with Database(self.database_url) as database: + result = await database.fetch_one(query) + if result: + return Bot( + app_id=result["app_id"], + enterprise_id=result["enterprise_id"], + team_id=result["team_id"], + bot_token=result["bot_token"], + bot_id=result["bot_id"], + bot_user_id=result["bot_user_id"], + bot_scopes=result["bot_scopes"], + installed_at=result["installed_at"], + ) + else: + return None + + +class AsyncSQLAlchemyOAuthStateStore(AsyncOAuthStateStore): + database_url: str + expiration_seconds: int + metadata: MetaData + oauth_states: Table + + def __init__( + self, + *, + expiration_seconds: int, + database_url: str, + logger: Logger = logging.getLogger(__name__), + ): + self.expiration_seconds = expiration_seconds + self.database_url = database_url + self._logger = logger + self.metadata = MetaData() + self.oauth_states = SQLAlchemyOAuthStateStore.build_oauth_states_table( + metadata=self.metadata, + table_name=SQLAlchemyOAuthStateStore.default_table_name, + ) + + @property + def logger(self) -> Logger: + return self._logger + + async def async_issue(self) -> str: + state: str = str(uuid4()) + now = datetime.utcfromtimestamp(time.time() + self.expiration_seconds) + async with Database(self.database_url) as database: + await database.execute(self.oauth_states.insert(), {"state": state, "expire_at": now}) + return state + + async def async_consume(self, state: str) -> bool: + try: + async with Database(self.database_url) as database: + async with database.transaction(): + c = self.oauth_states.c + query = self.oauth_states.select().where(and_(c.state == state, c.expire_at > datetime.utcnow())) + row = await database.fetch_one(query) + self.logger.debug(f"consume's query result: {row}") + await database.execute(self.oauth_states.delete().where(c.id == row["id"])) + return True + return False + except Exception as e: + message = f"Failed to find any persistent data for state: {state} - {e}" + self.logger.warning(message) + return False + + +database_url = "sqlite:///slackapp.db" +# database_url = "postgresql://localhost/slackapp" # pip install psycopg2 databases[postgresql] + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], +) + +installation_store = AsyncSQLAlchemyInstallationStore( + client_id=client_id, + database_url=database_url, + logger=logger, +) +oauth_state_store = AsyncSQLAlchemyOAuthStateStore( + expiration_seconds=120, + database_url=database_url, + logger=logger, +) + +app = AsyncApp( + logger=logger, + signing_secret=signing_secret, + installation_store=installation_store, + oauth_settings=AsyncOAuthSettings( + client_id=client_id, + client_secret=client_secret, + state_store=oauth_state_store, + ), +) +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_command(say: AsyncSay): + await say("Hi!") + + +from sanic import Sanic +from sanic.request import Request + +api = Sanic(name="awesome-slack-app") + + +@api.post("/slack/events") +async def endpoint(req: Request): + return await app_handler.handle(req) + + +@api.get("/slack/install") +async def install(req: Request): + return await app_handler.handle(req) + + +@api.get("/slack/oauth_redirect") +async def oauth_redirect(req: Request): + return await app_handler.handle(req) + + +async def init(): + try: + async with Database(database_url) as database: + await database.fetch_one("select count(*) from slack_bots") + except Exception: + engine = sqlalchemy.create_engine(database_url) + installation_store.metadata.create_all(engine) + oauth_state_store.metadata.create_all(engine) + + +if __name__ == "__main__": + import asyncio + + logging.basicConfig(level=logging.DEBUG) + asyncio.run(init()) + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + +# pip install -r requirements_async.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write + +# python async_oauth_app.py +# or +# uvicorn async_oauth_app:api --reload --port 3000 --log-level warning diff --git a/examples/sqlalchemy/oauth_app.py b/examples/sqlalchemy/oauth_app.py new file mode 100644 index 000000000..4ad7f8ff3 --- /dev/null +++ b/examples/sqlalchemy/oauth_app.py @@ -0,0 +1,91 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) +logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + +import os +from slack_bolt import App +from slack_bolt.adapter.flask import SlackRequestHandler +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store.sqlalchemy import SQLAlchemyInstallationStore +from slack_sdk.oauth.state_store.sqlalchemy import SQLAlchemyOAuthStateStore + +import sqlalchemy +from sqlalchemy.engine import Engine + +database_url = "sqlite:///slackapp.db" +# database_url = "postgresql://localhost/slackapp" # pip install psycopg2 + +logger = logging.getLogger(__name__) +client_id, client_secret, signing_secret = ( + os.environ["SLACK_CLIENT_ID"], + os.environ["SLACK_CLIENT_SECRET"], + os.environ["SLACK_SIGNING_SECRET"], +) + +engine: Engine = sqlalchemy.create_engine(database_url) +installation_store = SQLAlchemyInstallationStore( + client_id=client_id, + engine=engine, + logger=logger, +) +oauth_state_store = SQLAlchemyOAuthStateStore( + expiration_seconds=120, + engine=engine, + logger=logger, +) + +try: + engine.execute("select count(*) from slack_bots") +except Exception as e: + installation_store.metadata.create_all(engine) + oauth_state_store.metadata.create_all(engine) + +app = App( + logger=logger, + signing_secret=signing_secret, + installation_store=installation_store, + oauth_settings=OAuthSettings( + client_id=client_id, + client_secret=client_secret, + state_store=oauth_state_store, + ), +) + + +@app.event("app_mention") +def handle_command(say): + say("Hi!") + + +from flask import Flask, request + +flask_app = Flask(__name__) +handler = SlackRequestHandler(app) + + +@flask_app.route("/slack/events", methods=["POST"]) +def slack_events(): + return handler.handle(request) + + +@flask_app.route("/slack/install", methods=["GET"]) +def install(): + return handler.handle(request) + + +@flask_app.route("/slack/oauth_redirect", methods=["GET"]) +def oauth_redirect(): + return handler.handle(request) + + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,chat:write + +# FLASK_APP=oauth_app.py FLASK_ENV=development flask run -p 3000 diff --git a/examples/sqlalchemy/requirements.txt b/examples/sqlalchemy/requirements.txt new file mode 100644 index 000000000..d4cd207d2 --- /dev/null +++ b/examples/sqlalchemy/requirements.txt @@ -0,0 +1,3 @@ +slack_bolt>=0.6 +Flask>1 +SQLAlchemy diff --git a/examples/sqlalchemy/requirements_async.txt b/examples/sqlalchemy/requirements_async.txt new file mode 100644 index 000000000..ef386d6f4 --- /dev/null +++ b/examples/sqlalchemy/requirements_async.txt @@ -0,0 +1,4 @@ +slack_bolt>=0.6 +sanic>=20,<21 +uvicorn<1 +databases[sqlite] \ No newline at end of file diff --git a/samples/starlette/app.py b/examples/starlette/app.py similarity index 60% rename from samples/starlette/app.py rename to examples/starlette/app.py index 8b70c38dd..82020b324 100644 --- a/samples/starlette/app.py +++ b/examples/starlette/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler @@ -12,8 +5,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") @@ -28,9 +21,7 @@ async def endpoint(req: Request): return await app_handler.handle(req) -api = Starlette( - debug=True, routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])] -) +api = Starlette(debug=True, routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])]) # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** diff --git a/samples/starlette/async_app.py b/examples/starlette/async_app.py similarity index 62% rename from samples/starlette/async_app.py rename to examples/starlette/async_app.py index e6df9cc45..2de984755 100644 --- a/samples/starlette/async_app.py +++ b/examples/starlette/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler @@ -12,8 +5,8 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") @@ -28,9 +21,7 @@ async def endpoint(req: Request): return await app_handler.handle(req) -api = Starlette( - debug=True, routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])] -) +api = Starlette(debug=True, routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])]) # pip install -r requirements.txt # export SLACK_SIGNING_SECRET=*** diff --git a/samples/starlette/async_oauth_app.py b/examples/starlette/async_oauth_app.py similarity index 81% rename from samples/starlette/async_oauth_app.py rename to examples/starlette/async_oauth_app.py index 90d37a1e4..9ea92e1a6 100644 --- a/samples/starlette/async_oauth_app.py +++ b/examples/starlette/async_oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler @@ -13,8 +6,8 @@ @app.event("app_mention") -async def handle_app_mentions(payload, say, logger): - logger.info(payload) +async def handle_app_mentions(body, say, logger): + logger.info(body) await say("What's up?") diff --git a/samples/starlette/oauth_app.py b/examples/starlette/oauth_app.py similarity index 81% rename from samples/starlette/oauth_app.py rename to examples/starlette/oauth_app.py index b024c69a3..704921298 100644 --- a/samples/starlette/oauth_app.py +++ b/examples/starlette/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler @@ -13,8 +6,8 @@ @app.event("app_mention") -def handle_app_mentions(payload, say, logger): - logger.info(payload) +def handle_app_mentions(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/starlette/requirements.txt b/examples/starlette/requirements.txt similarity index 100% rename from samples/starlette/requirements.txt rename to examples/starlette/requirements.txt diff --git a/samples/tornado/app.py b/examples/tornado/app.py similarity index 63% rename from samples/tornado/app.py rename to examples/tornado/app.py index 4dcaf4d1d..ae0dec036 100644 --- a/samples/tornado/app.py +++ b/examples/tornado/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -16,14 +9,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) - return next() +def log_request(logger, body, next_): + logger.debug(body) + next_() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/examples/tornado/async_app.py b/examples/tornado/async_app.py new file mode 100644 index 000000000..41dc2649f --- /dev/null +++ b/examples/tornado/async_app.py @@ -0,0 +1,35 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.tornado.async_handler import AsyncSlackEventsHandler + +app = AsyncApp() + + +@app.middleware # or app.use(log_request) +async def log_request(logger, body, next_): + logger.debug(body) + await next_() + + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + + +from tornado.web import Application +from tornado.ioloop import IOLoop + +api = Application([("/slack/events", AsyncSlackEventsHandler, dict(app=app))]) + +if __name__ == "__main__": + api.listen(3000) + IOLoop.current().start() + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python async_app.py diff --git a/examples/tornado/async_oauth_app.py b/examples/tornado/async_oauth_app.py new file mode 100644 index 000000000..33f1b8e4f --- /dev/null +++ b/examples/tornado/async_oauth_app.py @@ -0,0 +1,45 @@ +import logging +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.tornado.async_handler import AsyncSlackEventsHandler, AsyncSlackOAuthHandler + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +@app.middleware # or app.use(log_request) +async def log_request(logger, body, next_): + logger.debug(body) + await next_() + + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + + +from tornado.web import Application +from tornado.ioloop import IOLoop + +api = Application( + [ + ("/slack/events", AsyncSlackEventsHandler, dict(app=app)), + ("/slack/install", AsyncSlackOAuthHandler, dict(app=app)), + ("/slack/oauth_redirect", AsyncSlackOAuthHandler, dict(app=app)), + ] +) + +if __name__ == "__main__": + api.listen(3000) + IOLoop.current().start() + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,chat:write + +# python async_oauth_app.py diff --git a/samples/tornado/oauth_app.py b/examples/tornado/oauth_app.py similarity index 72% rename from samples/tornado/oauth_app.py rename to examples/tornado/oauth_app.py index 997146258..c1072228d 100644 --- a/samples/tornado/oauth_app.py +++ b/examples/tornado/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.tornado import SlackEventsHandler, SlackOAuthHandler @@ -14,14 +7,14 @@ @app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) - return next() +def log_request(logger, body, next): + logger.debug(body) + next() @app.event("app_mention") -def event_test(ack, payload, say, logger): - logger.info(payload) +def event_test(body, say, logger): + logger.info(body) say("What's up?") diff --git a/samples/tornado/requirements.txt b/examples/tornado/requirements.txt similarity index 100% rename from samples/tornado/requirements.txt rename to examples/tornado/requirements.txt diff --git a/examples/workflow_steps/async_steps_from_apps.py b/examples/workflow_steps/async_steps_from_apps.py new file mode 100644 index 000000000..ed108cf5e --- /dev/null +++ b/examples/workflow_steps/async_steps_from_apps.py @@ -0,0 +1,169 @@ +import logging + +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient +from slack_bolt.async_app import AsyncApp, AsyncAck +from slack_bolt.workflows.step.async_step import ( + AsyncConfigure, + AsyncUpdate, + AsyncComplete, + AsyncFail, +) + +################################################################################ +# Steps from apps for legacy workflows are now deprecated. # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # +################################################################################ + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = AsyncApp() + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + user: AsyncSlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: AsyncSlackResponse = await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as e: + await fail(error={"message": f"Something wrong! (error: {e})"}) + + +app.step( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/workflow_steps/async_steps_from_apps_decorator.py b/examples/workflow_steps/async_steps_from_apps_decorator.py new file mode 100644 index 000000000..e04884723 --- /dev/null +++ b/examples/workflow_steps/async_steps_from_apps_decorator.py @@ -0,0 +1,191 @@ +import asyncio +import logging + +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient +from slack_bolt.async_app import AsyncApp, AsyncAck +from slack_bolt.workflows.step.async_step import ( + AsyncConfigure, + AsyncUpdate, + AsyncComplete, + AsyncFail, + AsyncWorkflowStep, +) + +################################################################################ +# Steps from apps for legacy workflows are now deprecated. # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # +################################################################################ + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = AsyncApp() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +@copy_review_step.edit +async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +async def additional_matcher(step): + email = str(step.get("inputs", {}).get("taskAuthorEmail")) + if "@" not in email: + return False + return True + + +async def noop_middleware(next): + return await next() + + +async def notify_execution(client: AsyncWebClient, step: dict): + await asyncio.sleep(5) + await client.chat_postMessage(channel="#random", text=f"Step execution: ```{step}```") + + +@copy_review_step.execute( + matchers=[additional_matcher], + middleware=[noop_middleware], + lazy=[notify_execution], +) +async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + user_lookup: AsyncSlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user_lookup["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) + + +app.step(copy_review_step) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/workflow_steps/async_steps_from_apps_primitive.py b/examples/workflow_steps/async_steps_from_apps_primitive.py new file mode 100644 index 000000000..06a2956db --- /dev/null +++ b/examples/workflow_steps/async_steps_from_apps_primitive.py @@ -0,0 +1,170 @@ +import logging + +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient +from slack_bolt.async_app import AsyncApp, AsyncAck + +################################################################################ +# Steps from apps for legacy workflows are now deprecated. # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # +################################################################################ + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = AsyncApp() + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) +async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): + await ack() + new_modal: AsyncSlackResponse = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": "copy_review_view", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ], + }, + ) + + +@app.view("copy_review_view") +async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): + state_values = body["view"]["state"]["values"] + response: AsyncSlackResponse = await client.api_call( + api_method="workflows.updateStep", + json={ + "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], + "inputs": { + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + "outputs": [ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + ) + await ack() + + +pseudo_database = {} + + +@app.event("workflow_step_execute") +async def execute(body: dict, client: AsyncWebClient): + step = body["event"]["workflow_step"] + completion: AsyncSlackResponse = await client.api_call( + api_method="workflows.stepCompleted", + json={ + "workflow_step_execute_id": step["workflow_step_execute_id"], + "outputs": { + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + }, + }, + ) + user: AsyncSlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: AsyncSlackResponse = await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/workflow_steps/steps_from_apps.py b/examples/workflow_steps/steps_from_apps.py new file mode 100644 index 000000000..b5f591700 --- /dev/null +++ b/examples/workflow_steps/steps_from_apps.py @@ -0,0 +1,173 @@ +import logging + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import App, Ack +from slack_bolt.workflows.step import Configure, Update, Complete, Fail + +################################################################################ +# Steps from apps for legacy workflows are now deprecated. # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # +################################################################################ + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +def edit(ack: Ack, step, configure: Configure): + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +def save(ack: Ack, view: dict, update: Update): + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: SlackResponse = client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": "Something wrong!"}) + + +app.step( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/workflow_steps/steps_from_apps_decorator.py b/examples/workflow_steps/steps_from_apps_decorator.py new file mode 100644 index 000000000..1558e825a --- /dev/null +++ b/examples/workflow_steps/steps_from_apps_decorator.py @@ -0,0 +1,194 @@ +import time +import logging + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import App, Ack +from slack_bolt.workflows.step import Configure, Update, Complete, Fail, WorkflowStep + +################################################################################ +# Steps from apps for legacy workflows are now deprecated. # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # +################################################################################ + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = WorkflowStep.builder("copy_review") + + +@copy_review_step.edit +def edit(ack: Ack, step, configure: Configure): + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +def save(ack: Ack, step: dict, view: dict, update: Update): + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +def additional_matcher(step): + email = str(step.get("inputs", {}).get("taskAuthorEmail")) + if "@" not in email: + return False + return True + + +def noop_middleware(next): + return next() + + +def notify_execution(client: WebClient, step: dict): + time.sleep(5) + client.chat_postMessage(channel="#random", text=f"Step execution: ```{step}```") + + +@copy_review_step.execute( + matchers=[additional_matcher], + middleware=[noop_middleware], + lazy=[notify_execution], +) +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user_lookup: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user_lookup["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) + + +app.step(copy_review_step) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/workflow_steps/steps_from_apps_primitive.py b/examples/workflow_steps/steps_from_apps_primitive.py new file mode 100644 index 000000000..dd4231ba6 --- /dev/null +++ b/examples/workflow_steps/steps_from_apps_primitive.py @@ -0,0 +1,172 @@ +import logging + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import App, Ack + +################################################################################ +# Steps from apps for legacy workflows are now deprecated. # +# Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ # +################################################################################ + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = App() + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +@app.action({"type": "workflow_step_edit", "callback_id": "copy_review"}) +def edit(body: dict, ack: Ack, client: WebClient): + ack() + new_modal: SlackResponse = client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": "copy_review_view", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ], + }, + ) + + +@app.view("copy_review_view") +def save(ack: Ack, client: WebClient, body: dict): + state_values = body["view"]["state"]["values"] + response: SlackResponse = client.api_call( + api_method="workflows.updateStep", + json={ + "workflow_step_edit_id": body["workflow_step"]["workflow_step_edit_id"], + "inputs": { + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + "outputs": [ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + ) + ack() + + +pseudo_database = {} + + +@app.event("workflow_step_execute") +def execute(body: dict, client: WebClient): + step = body["event"]["workflow_step"] + completion: SlackResponse = client.api_call( + api_method="workflows.stepCompleted", + json={ + "workflow_step_execute_id": step["workflow_step_execute_id"], + "outputs": { + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + }, + }, + ) + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + home_tab_update: SlackResponse = client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/wsgi/app.py b/examples/wsgi/app.py new file mode 100644 index 000000000..d994ffbf9 --- /dev/null +++ b/examples/wsgi/app.py @@ -0,0 +1,19 @@ +from slack_bolt import App +from slack_bolt.adapter.wsgi import SlackRequestHandler + +app = App() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +api = SlackRequestHandler(app) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# gunicorn app:api -b 0.0.0.0:3000 --log-level debug +# ngrok http 3000 diff --git a/examples/wsgi/oauth_app.py b/examples/wsgi/oauth_app.py new file mode 100644 index 000000000..bdb844fd4 --- /dev/null +++ b/examples/wsgi/oauth_app.py @@ -0,0 +1,23 @@ +from slack_bolt import App +from slack_bolt.adapter.wsgi import SlackRequestHandler + +app = App() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +api = SlackRequestHandler(app) + +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write + +# gunicorn oauth_app:api -b 0.0.0.0:3000 --log-level debug diff --git a/examples/wsgi/requirements.txt b/examples/wsgi/requirements.txt new file mode 100644 index 000000000..5c3ac5752 --- /dev/null +++ b/examples/wsgi/requirements.txt @@ -0,0 +1 @@ +gunicorn<23 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..ac197c4f6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "slack_bolt" +dynamic = ["version", "readme", "authors"] +description = "The Bolt Framework for Python" +license = { text = "MIT" } +classifiers = [ + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +requires-python = ">=3.7" +dependencies = ["slack_sdk>=3.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*"] + +[tool.setuptools.dynamic] +version = { attr = "slack_bolt.version.__version__" } +readme = { file = ["README.md"], content-type = "text/markdown" } + +[tool.distutils.bdist_wheel] +universal = true + +[tool.black] +line-length = 125 + +[tool.pytest.ini_options] +testpaths = ["tests"] +log_file = "logs/pytest.log" +log_file_level = "DEBUG" +log_format = "%(asctime)s %(levelname)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" +filterwarnings = [] +asyncio_mode = "auto" + +[tool.mypy] +files = "slack_bolt/" +force_union_syntax = true +warn_unused_ignores = true diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 48ce6a5fe..000000000 --- a/pytest.ini +++ /dev/null @@ -1,8 +0,0 @@ -[pytest] -log_file = logs/pytest.log -log_file_level = DEBUG -log_format = %(asctime)s %(levelname)s %(message)s -log_date_format = %Y-%m-%d %H:%M:%S -filterwarnings = - ignore:"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead:DeprecationWarning - ignore:The loop argument is deprecated since Python 3.8, and scheduled for removal in Python 3.10.:DeprecationWarning 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/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/samples/aws_chalice/requirements.txt b/samples/aws_chalice/requirements.txt deleted file mode 100644 index 050991ad6..000000000 --- a/samples/aws_chalice/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -slack_sdk==3.0.0a3 \ No newline at end of file diff --git a/samples/aws_lambda/.gitignore b/samples/aws_lambda/.gitignore deleted file mode 100644 index a725465ae..000000000 --- a/samples/aws_lambda/.gitignore +++ /dev/null @@ -1 +0,0 @@ -vendor/ \ No newline at end of file diff --git a/samples/aws_lambda/deploy_oauth.sh b/samples/aws_lambda/deploy_oauth.sh deleted file mode 100755 index 58f448907..000000000 --- a/samples/aws_lambda/deploy_oauth.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -rm -rf vendor && mkdir -p vendor/slack_bolt && cp -pr ../../slack_bolt/* vendor/slack_bolt/ -pip install python-lambda -U -lambda deploy \ - --config-file aws_lambda_oauth_config.yaml \ - --requirements requirements_oauth.txt \ No newline at end of file diff --git a/samples/aws_lambda/requirements.txt b/samples/aws_lambda/requirements.txt deleted file mode 100644 index 050991ad6..000000000 --- a/samples/aws_lambda/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -slack_sdk==3.0.0a3 \ No newline at end of file diff --git a/samples/aws_lambda/requirements_oauth.txt b/samples/aws_lambda/requirements_oauth.txt deleted file mode 100644 index 050991ad6..000000000 --- a/samples/aws_lambda/requirements_oauth.txt +++ /dev/null @@ -1 +0,0 @@ -slack_sdk==3.0.0a3 \ No newline at end of file diff --git a/samples/django/.gitignore b/samples/django/.gitignore deleted file mode 100644 index ba520ccd8..000000000 --- a/samples/django/.gitignore +++ /dev/null @@ -1 +0,0 @@ -db.sqlite3 \ No newline at end of file diff --git a/samples/django/README.md b/samples/django/README.md deleted file mode 100644 index e414848ac..000000000 --- a/samples/django/README.md +++ /dev/null @@ -1,8 +0,0 @@ -``` -pip install -r requirements.txt -export SLACK_SIGNING_SECRET=*** -export SLACK_BOT_TOKEN=xoxb-*** -cd ./slackapp -python manage.py migrate -python manage.py runserver 0.0.0.0:3000 -``` \ No newline at end of file diff --git a/samples/django/requirements.txt b/samples/django/requirements.txt deleted file mode 100644 index ec9338fd2..000000000 --- a/samples/django/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -Django>=3,<4 \ No newline at end of file diff --git a/samples/django/slackapp/slackapp/settings.py b/samples/django/slackapp/slackapp/settings.py deleted file mode 100644 index f91069c71..000000000 --- a/samples/django/slackapp/slackapp/settings.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Django settings for slackapp project. - -Generated by 'django-admin startproject' using Django 3.0.8. - -For more information on this file, see -https://docs.djangoproject.com/en/3.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.0/ref/settings/ -""" - -import os - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": {"console": {"class": "logging.StreamHandler",},}, - "root": {"handlers": ["console"], "level": "INFO",}, - "loggers": { - "django": { - "handlers": ["console"], - "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), - "propagate": False, - }, - "slack_bolt": {"handlers": ["console"], "level": "DEBUG", "propagate": False,}, - }, -} - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -# TODO: CHANGE THIS IF YOU REUSE THIS APP -SECRET_KEY = "This is just a example. You should not expose your secret key in real apps" - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -# ALLOWED_HOSTS = [] -ALLOWED_HOSTS = ["*"] - -# Application definition - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "slackapp.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "slackapp.wsgi.application" - -# Database -# https://docs.djangoproject.com/en/3.0/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } -} - -# Password validation -# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, - {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, - {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, -] - -# Internationalization -# https://docs.djangoproject.com/en/3.0/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.0/howto/static-files/ - -STATIC_URL = "/static/" diff --git a/samples/django/slackapp/slackapp/urls.py b/samples/django/slackapp/slackapp/urls.py deleted file mode 100644 index e76b9a114..000000000 --- a/samples/django/slackapp/slackapp/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path("slack/events", views.handle, name="handle"), - path("slack/install", views.handle, name="install"), - path("slack/oauth_redirect", views.handle, name="oauth_redirect"), -] diff --git a/samples/events_app.py b/samples/events_app.py deleted file mode 100644 index 11827fdc9..000000000 --- a/samples/events_app.py +++ /dev/null @@ -1,73 +0,0 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - -import re -import logging - -logging.basicConfig(level=logging.DEBUG) - -from slack_bolt import App - -app = App() - - -@app.middleware # or app.use(log_request) -def log_request(logger, payload, next): - logger.debug(payload) - return next() - - -@app.event("app_mention") -def event_test(payload, say, logger): - logger.info(payload) - say("What's up?") - - -@app.message("test") -def test_message(logger, payload): - logger.info(payload) - - -@app.message(re.compile("bug")) -def mention_bug(logger, payload): - logger.info(payload) - - -@app.event( - event={"type": "message", "subtype": "message_deleted"}, - matchers=[ - lambda payload: payload["event"]["previous_message"].get("bot_id", None) is None - ], -) -def deleted(payload, say): - message = payload["event"]["previous_message"]["text"] - say(f"I've noticed you deleted: {message}") - - -def print_bot(req, resp, next): - bot_id = req.global_shortcut_payload["event"]["previous_message"]["bot_id"] - logger = logging.getLogger(__name__) - logger.info(f"bot_id surely exists here: {bot_id}") - return next() - - -@app.event( - event={"type": "message", "subtype": "message_deleted"}, - matchers=[lambda payload: payload["event"]["previous_message"].get("bot_id", None)], - middleware=[print_bot], -) -def bot_message_deleted(logger): - logger.info("A bot message has been deleted") - - -if __name__ == "__main__": - app.start(3000) - -# pip install slack_bolt -# export SLACK_SIGNING_SECRET=*** -# export SLACK_BOT_TOKEN=xoxb-*** -# python events_app.py diff --git a/samples/falcon/requirements.txt b/samples/falcon/requirements.txt deleted file mode 100644 index 20b4e2937..000000000 --- a/samples/falcon/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -falcon>=2,<3 -gunicorn>=20,<21 \ No newline at end of file diff --git a/scripts/build_pypi_package.sh b/scripts/build_pypi_package.sh index eb073b6b0..5806262a6 100755 --- a/scripts/build_pypi_package.sh +++ b/scripts/build_pypi_package.sh @@ -1,8 +1,11 @@ #!/bin/bash -./scripts/run_tests.sh && \ - pip install -U pip && \ - pip install twine wheel && \ +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + +pip install -U pip && \ + pip install -U twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python setup.py sdist bdist_wheel && \ - twine check dist/* \ No newline at end of file + python -m build --sdist --wheel && \ + twine check dist/* diff --git a/scripts/deploy_to_prod_pypi_org.sh b/scripts/deploy_to_prod_pypi_org.sh deleted file mode 100755 index 8c9ef5bca..000000000 --- a/scripts/deploy_to_prod_pypi_org.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -./scripts/run_tests.sh && \ - pip install -U pip && \ - pip install twine wheel && \ - rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python setup.py sdist bdist_wheel && \ - twine check dist/* && \ - twine upload dist/* diff --git a/scripts/deploy_to_test_pypi.sh b/scripts/deploy_to_test_pypi.sh new file mode 100644 index 000000000..a6b9c352d --- /dev/null +++ b/scripts/deploy_to_test_pypi.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + +pip install -U pip && \ + pip install -U twine build && \ + rm -rf dist/ build/ slack_bolt.egg-info/ && \ + python -m build --sdist --wheel && \ + twine check dist/* && \ + twine upload --repository testpypi dist/* \ No newline at end of file diff --git a/scripts/deploy_to_test_pypi_org.sh b/scripts/deploy_to_test_pypi_org.sh deleted file mode 100755 index 37583036f..000000000 --- a/scripts/deploy_to_test_pypi_org.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -./scripts/run_tests.sh && \ - pip install -U pip && \ - pip install twine wheel && \ - rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python setup.py sdist bdist_wheel && \ - twine check dist/* && \ - twine upload --repository testpypi dist/* diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 000000000..771cbb413 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# ./scripts/format.sh + +script_dir=`dirname $0` +cd ${script_dir}/.. + +if [[ "$1" != "--no-install" ]]; then + export PIP_REQUIRE_VIRTUALENV=1 + pip install -U pip + 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 new file mode 100755 index 000000000..275aa0fe1 --- /dev/null +++ b/scripts/generate_api_docs.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Generate API documents from the latest source code + +set -e +script_dir=$(dirname "$0") +cd "${script_dir}/.." + +pip install -U pip +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 + +pdoc slack_bolt --html -o docs/reference +cp -R docs/reference/slack_bolt/* docs/reference/ +rm -rf docs/reference/slack_bolt + +open docs/reference/index.html diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..64cdf1561 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Installs all dependencies of the project +# ./scripts/install.sh + +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + +# Update pip to prevent warnings +pip install -U pip + +# The package causes a conflict with moto +pip uninstall python-lambda + +pip install -U -e . +pip install -U -r requirements/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/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh new file mode 100755 index 000000000..939e71ffd --- /dev/null +++ b/scripts/install_all_and_run_tests.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Run all the tests or a single test +# all: ./scripts/install_all_and_run_tests.sh +# single: ./scripts/install_all_and_run_tests.sh tests/scenario_tests/test_app.py + +script_dir=`dirname $0` +cd ${script_dir}/.. + +test_target="${1:-tests/}" + +# keep in sync with LATEST_SUPPORTED_PY in .github/workflows/ci-build.yml +LATEST_SUPPORTED_PY="3.14" +current_py=$(python --version | sed -E 's/Python ([0-9]+\.[0-9]+).*/\1/') + +./scripts/install.sh + +./scripts/format.sh --no-install +./scripts/lint.sh --no-install +pytest $test_target + +if [[ "$current_py" == "$LATEST_SUPPORTED_PY" ]]; then + ./scripts/run_mypy.sh --no-install +fi diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 000000000..3a3037419 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# ./scripts/lint.sh + +script_dir=$(dirname $0) +cd ${script_dir}/.. + +if [[ "$1" != "--no-install" ]]; then + pip install -U pip + pip install -U -r requirements/dev_tools.txt +fi + +flake8 slack_bolt/ && flake8 examples/ diff --git a/scripts/run_mypy.sh b/scripts/run_mypy.sh new file mode 100755 index 000000000..c9234f87a --- /dev/null +++ b/scripts/run_mypy.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# ./scripts/run_mypy.sh + +script_dir=$(dirname $0) +cd ${script_dir}/.. + +if [[ "$1" != "--no-install" ]]; then + pip install -U pip + pip install -U . + 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/scripts/run_tests.sh b/scripts/run_tests.sh index 0a49d4e42..d4dc767e3 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -6,7 +6,7 @@ script_dir=`dirname $0` cd ${script_dir}/.. -pip install -e ".[testing]" && \ - black slack_bolt/ tests/ && \ - pytest $1 && \ - pytype slack_bolt/ \ No newline at end of file +test_target="${1:-tests/}" + +./scripts/format.sh --no-install +pytest -vv $test_target diff --git a/scripts/uninstall_all.sh b/scripts/uninstall_all.sh index 188f97a41..1d3da265d 100755 --- a/scripts/uninstall_all.sh +++ b/scripts/uninstall_all.sh @@ -1,3 +1,4 @@ #!/bin/bash -pip freeze | grep -v "^-e" | xargs pip uninstall -y \ No newline at end of file +pip uninstall -y slack-bolt && \ + pip freeze | grep -v "^-e" | xargs pip uninstall -y diff --git a/setup.cfg b/setup.cfg index d5cab2802..3b05a6fa3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ -[bdist_wheel] -universal = 1 - -[aliases] -test=pytest \ No newline at end of file +; Legacy package configuration, prefer pyproject.toml over setup.cfg +[metadata] +url=https://github.com/slackapi/bolt-python +author=Slack Technologies, LLC +author_email=opensource@slack.com diff --git a/setup.py b/setup.py deleted file mode 100755 index e0a63dfda..000000000 --- a/setup.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python -import os - -import setuptools - -here = os.path.abspath(os.path.dirname(__file__)) - -__version__ = None -exec(open(f"{here}/slack_bolt/version.py").read()) - -with open(f"{here}/README.md", "r") as fh: - long_description = fh.read() - -test_dependencies = [ - "pytest>=5,<6", - "pytest-asyncio<1", # for async - "aiohttp>=3,<4", # for async - "black==19.10b0", - "pytype", -] - -setuptools.setup( - name="slack_bolt", - version=__version__, - license="MIT", - author="Slack Technologies, Inc.", - author_email="opensource@slack.com", - description="The Bolt Framework for Python", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/slackapi/bolt-python", - packages=setuptools.find_packages( - exclude=[ - "samples", - "integration_tests", - "tests", - "tests.*", - ] - ), - include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk==3.0.0a4", ], - setup_requires=["pytest-runner==5.2"], - tests_require=test_dependencies, - test_suite="tests", - extras_require={ - # pip install -e ".[async]" - "async": [ - # async features heavily depends on aiohttp - "aiohttp>=3,<4", - ], - # pip install -e ".[adapter]" - "adapter": [ - # any of async ones - "aiohttp>=3,<4", - # used only under src/slack_bolt/adapter - "boto3<=2", - "bottle>=0.12,<1", - "chalice>=1,<2", - "click>=7,<8", # for chalice - "Django>=3,<4", - "falcon>=2,<3", - "fastapi<1", - "Flask>=1,<2", - "pyramid>=1,<2", - "sanic>=20,<21", - "starlette>=0.13,<1", - "tornado>=6,<7", - # server - "uvicorn<1", - "gunicorn>=20,<21", - ], - # pip install -e ".[testing]" - "testing": test_dependencies, - }, - classifiers=[ - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: Implementation :: CPython", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.6", -) diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 3f7e5f9b0..e3664814b 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -1,13 +1,59 @@ +""" +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 +* The class representing a Bolt app: `slack_bolt.app.app` +""" # noqa: E501 + # Don't add async module imports here -from .app import App # noqa -from .context import BoltContext # noqa -from .context.ack import Ack # noqa -from .context.respond import Respond # noqa -from .context.say import Say # noqa -from .kwargs_injection import Args # noqa -from .kwargs_injection import Args # noqa -from .listener import Listener # noqa -from .listener_matcher import CustomListenerMatcher # noqa -from .request import BoltRequest # noqa -from .response import BoltResponse # noqa -from .response import BoltResponse # noqa +from .app import App +from .context import BoltContext +from .context.ack import Ack +from .context.complete import Complete +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 +from .request import BoltRequest +from .response import BoltResponse + +# AI Agents & Assistants +from .middleware.assistant.assistant import ( + Assistant, +) +from .context.assistant.thread_context import AssistantThreadContext +from .context.assistant.thread_context_store.store import AssistantThreadContextStore +from .context.assistant.thread_context_store.file import FileAssistantThreadContextStore + +from .context.set_status import SetStatus +from .context.set_title import SetTitle +from .context.set_suggested_prompts import SetSuggestedPrompts +from .context.save_thread_context import SaveThreadContext + +__all__ = [ + "App", + "BoltContext", + "Ack", + "Complete", + "Fail", + "Respond", + "Say", + "SayStream", + "Args", + "Listener", + "CustomListenerMatcher", + "BoltRequest", + "BoltResponse", + "Assistant", + "AssistantThreadContext", + "AssistantThreadContextStore", + "FileAssistantThreadContextStore", + "SetStatus", + "SetTitle", + "SetSuggestedPrompts", + "SaveThreadContext", +] diff --git a/slack_bolt/adapter/__init__.py b/slack_bolt/adapter/__init__.py index e69de29bb..9ca556e52 100644 --- a/slack_bolt/adapter/__init__.py +++ b/slack_bolt/adapter/__init__.py @@ -0,0 +1 @@ +"""Adapter modules for running Bolt apps along with Web frameworks or Socket Mode.""" diff --git a/slack_bolt/adapter/aiohttp/__init__.py b/slack_bolt/adapter/aiohttp/__init__.py index f4d347d3f..9267f2515 100644 --- a/slack_bolt/adapter/aiohttp/__init__.py +++ b/slack_bolt/adapter/aiohttp/__init__.py @@ -8,7 +8,9 @@ async def to_bolt_request(request: web.Request) -> AsyncBoltRequest: return AsyncBoltRequest( - body=await request.text(), query=request.query_string, headers=request.headers, + body=await request.text(), + query=request.query_string, + headers=request.headers, # type: ignore[arg-type] ) @@ -29,11 +31,17 @@ async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response: resp.set_cookie( name=name, value=c.value, - max_age=c.get("max-age", None), - expires=c.get("expires", None), - path=c.get("path", None), - domain=c.get("domain", None), + max_age=c.get("max-age"), + expires=c.get("expires"), + path=c.get("path"), # type: ignore[arg-type] + domain=c.get("domain"), secure=True, httponly=True, ) return resp + + +__all__ = [ + "to_bolt_request", + "to_aiohttp_response", +] diff --git a/slack_bolt/adapter/asgi/__init__.py b/slack_bolt/adapter/asgi/__init__.py new file mode 100644 index 000000000..94db0620d --- /dev/null +++ b/slack_bolt/adapter/asgi/__init__.py @@ -0,0 +1,3 @@ +from .builtin import SlackRequestHandler + +__all__ = ["SlackRequestHandler"] diff --git a/slack_bolt/adapter/asgi/aiohttp/__init__.py b/slack_bolt/adapter/asgi/aiohttp/__init__.py new file mode 100644 index 000000000..aed8458d9 --- /dev/null +++ b/slack_bolt/adapter/asgi/aiohttp/__init__.py @@ -0,0 +1,49 @@ +from slack_bolt.adapter.asgi.http_request import AsgiHttpRequest +from slack_bolt.adapter.asgi.builtin import SlackRequestHandler + +from slack_bolt.async_app import AsyncApp + +from slack_bolt.async_app import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +class AsyncSlackRequestHandler(SlackRequestHandler): + app: AsyncApp + + def __init__(self, app: AsyncApp, path: str = "/slack/events"): + """Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. + This can be used for production deployment. + + With the default settings, `http://localhost:3000/slack/events` + Run Bolt with [uvicron](https://www.uvicorn.org/) + + # Python + app = AsyncApp() + api = SlackRequestHandler(app) + + # bash + export SLACK_SIGNING_SECRET=*** + export SLACK_BOT_TOKEN=xoxb-*** + uvicorn app:api --port 3000 --log-level debug + + Args: + app: Your bolt application + path: The path to handle request from Slack (Default: `/slack/events`) + """ + self.path = path + self.app = app + + async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse: + return await self.app.async_dispatch( + AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) + ) + + async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse: + return await self.app.oauth_flow.handle_installation( # type: ignore[union-attr] + AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) + ) + + async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse: + return await self.app.oauth_flow.handle_callback( # type: ignore[union-attr] + AsyncBoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) + ) diff --git a/slack_bolt/adapter/asgi/async_handler.py b/slack_bolt/adapter/asgi/async_handler.py new file mode 100644 index 000000000..4820f010e --- /dev/null +++ b/slack_bolt/adapter/asgi/async_handler.py @@ -0,0 +1,3 @@ +from .aiohttp import AsyncSlackRequestHandler + +__all__ = ["AsyncSlackRequestHandler"] diff --git a/slack_bolt/adapter/asgi/base_handler.py b/slack_bolt/adapter/asgi/base_handler.py new file mode 100644 index 000000000..adfa060c1 --- /dev/null +++ b/slack_bolt/adapter/asgi/base_handler.py @@ -0,0 +1,69 @@ +from typing import Callable, Union + +from .http_request import AsgiHttpRequest +from .http_response import AsgiHttpResponse +from .utils import scope_type + +from slack_bolt import App + +from slack_bolt.response import BoltResponse + +""" +This handler implements the ASGI standard found here https://asgi.readthedocs.io/en/latest/specs/index.html +""" + + +class BaseSlackRequestHandler: + app: Union[App, "AsyncApp"] # type: ignore[name-defined] + path: str + + async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse: + """Dispatches a request to the Bolt App""" + raise NotImplementedError + + async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse: + """Handles installation of the OAuthFlow""" + raise NotImplementedError + + async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse: + """Handles the callback of the OAuthFlow""" + raise NotImplementedError + + async def _get_http_response(self, method: str, path: str, request: AsgiHttpRequest) -> AsgiHttpResponse: + if method == "GET": + if self.app.oauth_flow is not None: + if path == self.app.oauth_flow.install_path: + bolt_response: BoltResponse = await self.handle_installation(request) + return AsgiHttpResponse( + status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body + ) + elif path == self.app.oauth_flow.redirect_uri_path: + bolt_response = await self.handle_callback(request) + return AsgiHttpResponse( + status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body + ) + if method == "POST" and path == self.path: + bolt_response = await self.dispatch(request) + 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, 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": + response: AsgiHttpResponse = await self._get_http_response( + method=scope["method"], path=scope["path"], request=AsgiHttpRequest(scope, receive) # type: ignore[arg-type] + ) + await send(response.get_response_start()) + await send(response.get_response_body()) + return + if scope["type"] == "lifespan": + await self._handle_lifespan(receive, send) + return + raise TypeError(f"Unsupported scope type: {scope['type']!r}") diff --git a/slack_bolt/adapter/asgi/builtin/__init__.py b/slack_bolt/adapter/asgi/builtin/__init__.py new file mode 100644 index 000000000..93f7ab845 --- /dev/null +++ b/slack_bolt/adapter/asgi/builtin/__init__.py @@ -0,0 +1,48 @@ +from slack_bolt.adapter.asgi.http_request import AsgiHttpRequest + +from slack_bolt import App + +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + +from slack_bolt.adapter.asgi.base_handler import BaseSlackRequestHandler + + +class SlackRequestHandler(BaseSlackRequestHandler): + def __init__(self, app: App, path: str = "/slack/events"): + """Setup Bolt as an ASGI web framework, this will make your application compatible with ASGI web servers. + This can be used for production deployment. + + With the default settings, `http://localhost:3000/slack/events` + Run Bolt with [uvicron](https://www.uvicorn.org/) + + # Python + app = App() + api = SlackRequestHandler(app) + + # bash + export SLACK_SIGNING_SECRET=*** + export SLACK_BOT_TOKEN=xoxb-*** + uvicorn app:api --port 3000 --log-level debug + + Args: + app: Your bolt application + path: The path to handle request from Slack (Default: `/slack/events`) + """ + self.path = path + self.app = app + + async def dispatch(self, request: AsgiHttpRequest) -> BoltResponse: + return self.app.dispatch( + BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) + ) + + async def handle_installation(self, request: AsgiHttpRequest) -> BoltResponse: + return self.app.oauth_flow.handle_installation( # type: ignore[union-attr] + BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) + ) + + async def handle_callback(self, request: AsgiHttpRequest) -> BoltResponse: + return self.app.oauth_flow.handle_callback( # type: ignore[union-attr] + BoltRequest(body=await request.get_raw_body(), query=request.query_string, headers=request.get_headers()) + ) diff --git a/slack_bolt/adapter/asgi/http_request.py b/slack_bolt/adapter/asgi/http_request.py new file mode 100644 index 000000000..1006df422 --- /dev/null +++ b/slack_bolt/adapter/asgi/http_request.py @@ -0,0 +1,28 @@ +from typing import Callable, Dict, Iterable, Sequence, Tuple, Union + +from .utils import scope_type, ENCODING + + +class AsgiHttpRequest: + __slots__ = ("receive", "query_string", "raw_headers") + + def __init__(self, scope: scope_type, receive: Callable): + self.receive = receive + self.query_string = str(scope["query_string"], ENCODING) # type: ignore[arg-type] + self.raw_headers: Iterable[Tuple[bytes, bytes]] = scope["headers"] # type: ignore[assignment] + + def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]: + return {str(header[0], ENCODING): str(header[1], (ENCODING)) for header in self.raw_headers} + + async def get_raw_body(self) -> str: + chunks = bytearray() + while True: + chunk: Dict[str, Union[str, bytes]] = await self.receive() + + if chunk["type"] != "http.request": + raise Exception("Body chunks could not be received from asgi server") + + chunks.extend(chunk.get("body", b"")) # type: ignore[arg-type] + if not chunk.get("more_body", False): + break + return bytes(chunks).decode(ENCODING) diff --git a/slack_bolt/adapter/asgi/http_response.py b/slack_bolt/adapter/asgi/http_response.py new file mode 100644 index 000000000..58969f137 --- /dev/null +++ b/slack_bolt/adapter/asgi/http_response.py @@ -0,0 +1,32 @@ +from typing import Iterable, Sequence, Tuple, Dict, Union, List + +from .utils import ENCODING + + +class AsgiHttpResponse: + __slots__ = ("status", "raw_headers", "body") + + def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): + self.status: int = status + 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 { + "type": "http.response.start", + "status": self.status, + "headers": self.raw_headers, + } + + def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]: + return { + "type": "http.response.body", + "body": self.body, + "more_body": False, + } diff --git a/slack_bolt/adapter/asgi/utils.py b/slack_bolt/adapter/asgi/utils.py new file mode 100644 index 000000000..3333695d8 --- /dev/null +++ b/slack_bolt/adapter/asgi/utils.py @@ -0,0 +1,7 @@ +from typing import Iterable, Tuple, Union, Dict + +ENCODING = "utf-8" # should always be utf-8 + +scope_value_type = Union[str, bytes, Iterable[Tuple[bytes, bytes]]] + +scope_type = Dict[str, scope_value_type] diff --git a/slack_bolt/adapter/aws_lambda/__init__.py b/slack_bolt/adapter/aws_lambda/__init__.py index f08c97a5f..83f4882db 100644 --- a/slack_bolt/adapter/aws_lambda/__init__.py +++ b/slack_bolt/adapter/aws_lambda/__init__.py @@ -1 +1,5 @@ from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/aws_lambda/chalice_handler.py b/slack_bolt/adapter/aws_lambda/chalice_handler.py index b65838479..00200b6da 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -1,7 +1,14 @@ import logging +from os import getenv +from typing import Optional + +from botocore.client import BaseClient # type: ignore[import-untyped] from chalice.app import Request, Response, Chalice +from slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner import ( + ChaliceLazyListenerRunner, +) from slack_bolt.adapter.aws_lambda.internals import _first_value from slack_bolt.app import App from slack_bolt.logger import get_bolt_app_logger @@ -11,10 +18,28 @@ class ChaliceSlackRequestHandler: - def __init__(self, app: App, chalice: Chalice): # type: ignore + def __init__(self, app: App, chalice: Chalice, lambda_client: Optional[BaseClient] = None): self.app = app self.chalice = chalice - self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler) + self.logger = get_bolt_app_logger(app.name, ChaliceSlackRequestHandler, app.logger) + + if getenv("AWS_CHALICE_CLI_MODE") == "true" and lambda_client is None: + try: + from slack_bolt.adapter.aws_lambda.local_lambda_client import ( + LocalLambdaClient, + ) + + lambda_client = LocalLambdaClient(self.chalice, None) # type: ignore[arg-type] + except ImportError: + logging.info("Failed to load LocalLambdaClient for CLI mode.") + pass + + self.app.listener_runner.lazy_listener_runner = ChaliceLazyListenerRunner( + logger=self.logger, lambda_client=lambda_client + ) + + if self.app.oauth_flow is not None: + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" @classmethod def clear_all_log_handlers(cls): @@ -25,7 +50,7 @@ def clear_all_log_handlers(cls): root.removeHandler(handler) def handle(self, request: Request): - body: str = request.raw_body.decode("utf-8") if request.raw_body else "" + body: str = request.raw_body.decode("utf-8") if request.raw_body else "" # type: ignore[union-attr] self.logger.debug(f"Incoming request: {request.to_dict()}, body: {body}") method = request.method @@ -37,10 +62,7 @@ def handle(self, request: Request): bolt_req: BoltRequest = to_bolt_request(request, body) query = bolt_req.query is_callback = query is not None and ( - ( - _first_value(query, "code") is not None - and _first_value(query, "state") is not None - ) + (_first_value(query, "code") is not None and _first_value(query, "state") is not None) or _first_value(query, "error") is not None ) if is_callback: @@ -50,7 +72,16 @@ def handle(self, request: Request): bolt_resp = oauth_flow.handle_installation(bolt_req) return to_chalice_response(bolt_resp) elif method == "POST": - bolt_req: BoltRequest = to_bolt_request(request, body) + bolt_req = to_bolt_request(request, body) + # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html + aws_lambda_function_name = self.chalice.lambda_context.function_name + bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name + bolt_req.context["chalice_request"] = request.to_dict() + bolt_resp = self.app.dispatch(bolt_req) + aws_response = to_chalice_response(bolt_resp) + return aws_response + elif method == "NONE": + bolt_req = to_bolt_request(request, body) bolt_resp = self.app.dispatch(bolt_req) aws_response = to_chalice_response(bolt_resp) return aws_response @@ -59,14 +90,24 @@ def handle(self, request: Request): def to_bolt_request(request: Request, body: str) -> BoltRequest: - return BoltRequest(body=body, query=request.query_params, headers=request.headers,) + return BoltRequest( + body=body, + query=request.query_params, # type: ignore[arg-type] + headers=request.headers, # type: ignore[arg-type] + ) def to_chalice_response(resp: BoltResponse) -> Response: return Response( - status_code=resp.status, body=resp.body, headers=resp.first_headers(), + status_code=resp.status, + body=resp.body, + headers=resp.first_headers(), # type: ignore[arg-type] ) def not_found() -> Response: - return Response(status_code=404, body="Not Found", headers={},) + return Response( + status_code=404, + body="Not Found", + headers={}, + ) diff --git a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py new file mode 100644 index 000000000..01c684e47 --- /dev/null +++ b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py @@ -0,0 +1,39 @@ +import json +from logging import Logger +from typing import Callable, Optional + +import boto3 # type: ignore[import-untyped] +from botocore.client import BaseClient # type: ignore[import-untyped] + +from slack_bolt import BoltRequest +from slack_bolt.lazy_listener import LazyListenerRunner + + +class ChaliceLazyListenerRunner(LazyListenerRunner): + def __init__(self, logger: Logger, lambda_client: Optional[BaseClient] = None): + self.lambda_client = lambda_client + self.logger = logger + + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + if self.lambda_client is None: + self.lambda_client = boto3.client("lambda") + + chalice_request: dict = request.context["chalice_request"] + request.headers["x-slack-bolt-lazy-only"] = ["1"] + request.headers["x-slack-bolt-lazy-function-name"] = [request.lazy_function_name] # type: ignore[list-item] + payload = { + "method": "NONE", + "headers": {k: v[0] for k, v in request.headers.items()}, + "multiValueQueryStringParameters": request.query, + "queryStringParameters": {k: v[0] for k, v in request.query.items()}, + "pathParameters": {}, + "stageVariables": {}, + "requestContext": chalice_request["context"], + "body": request.raw_body, + "isBase64Encoded": False, + } + invocation = self.lambda_client.invoke( + FunctionName=request.context["aws_lambda_function_name"], + InvocationType="Event", + Payload=json.dumps(payload), + ) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index f7faee016..4d155e58f 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -1,8 +1,9 @@ import base64 import logging -from typing import List, Dict, Any +from typing import Dict, Any, Sequence from slack_bolt.adapter.aws_lambda.internals import _first_value +from slack_bolt.adapter.aws_lambda.lazy_listener_runner import LambdaLazyListenerRunner from slack_bolt.app import App from slack_bolt.logger import get_bolt_app_logger from slack_bolt.oauth import OAuthFlow @@ -11,9 +12,12 @@ class SlackRequestHandler: - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app - self.logger = get_bolt_app_logger(app.name, SlackRequestHandler) + self.logger = get_bolt_app_logger(app.name, SlackRequestHandler, app.logger) + self.app.listener_runner.lazy_listener_runner = LambdaLazyListenerRunner(self.logger) + if self.app.oauth_flow is not None: + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" @classmethod def clear_all_log_handlers(cls): @@ -26,7 +30,10 @@ def clear_all_log_handlers(cls): def handle(self, event, context): self.logger.debug(f"Incoming event: {event}, context: {context}") - method = event.get("requestContext", {}).get("http", {}).get("method", None) + method = event.get("requestContext", {}).get("http", {}).get("method") + if method is None: + method = event.get("requestContext", {}).get("httpMethod") + if method is None: return not_found() if method == "GET": @@ -35,10 +42,7 @@ def handle(self, event, context): bolt_req: BoltRequest = to_bolt_request(event) query = bolt_req.query is_callback = query is not None and ( - ( - _first_value(query, "code") is not None - and _first_value(query, "state") is not None - ) + (_first_value(query, "code") is not None and _first_value(query, "state") is not None) or _first_value(query, "error") is not None ) if is_callback: @@ -48,6 +52,16 @@ def handle(self, event, context): bolt_resp = oauth_flow.handle_installation(bolt_req) return to_aws_response(bolt_resp) elif method == "POST": + bolt_req = to_bolt_request(event) + # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html + aws_lambda_function_name = context.function_name + bolt_req.context["aws_lambda_function_name"] = aws_lambda_function_name + bolt_req.context["aws_lambda_invoked_function_arn"] = context.invoked_function_arn + bolt_req.context["lambda_request"] = event + bolt_resp = self.app.dispatch(bolt_req) + aws_response = to_aws_response(bolt_resp) + return aws_response + elif method == "NONE": bolt_req = to_bolt_request(event) bolt_resp = self.app.dispatch(bolt_req) aws_response = to_aws_response(bolt_resp) @@ -60,11 +74,20 @@ def to_bolt_request(event) -> BoltRequest: body = event.get("body", "") if event["isBase64Encoded"]: body = base64.b64decode(body).decode("utf-8") - cookies: List[str] = event.get("cookies", []) + cookies: Sequence[str] = event.get("cookies", []) + if cookies is None or len(cookies) == 0: + # In the case of format v1 + multiValueHeaders = event.get("multiValueHeaders", {}) + cookies = multiValueHeaders.get("cookie", []) + if len(cookies) == 0: + # Try using uppercase + cookies = multiValueHeaders.get("Cookie", []) headers = event.get("headers", {}) headers["cookie"] = cookies return BoltRequest( - body=body, query=event.get("queryStringParameters", {}), headers=headers, + body=body, + query=event.get("queryStringParameters", {}), + headers=headers, ) diff --git a/slack_bolt/adapter/aws_lambda/internals.py b/slack_bolt/adapter/aws_lambda/internals.py index f5700b8a8..944d17e82 100644 --- a/slack_bolt/adapter/aws_lambda/internals.py +++ b/slack_bolt/adapter/aws_lambda/internals.py @@ -1,7 +1,7 @@ -from typing import Dict, List, Optional +from typing import Dict, Optional, Sequence -def _first_value(query: Dict[str, List[str]], name: str) -> Optional[str]: +def _first_value(query: Dict[str, Sequence[str]], name: str) -> Optional[str]: if query: values = query.get(name, []) if values and len(values) > 0: diff --git a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py index 6d3784995..46588e35d 100644 --- a/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py +++ b/slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py @@ -3,13 +3,15 @@ from logging import Logger from typing import Optional -import boto3 +import boto3 # type: ignore[import-untyped] +from slack_bolt.authorization.authorize import InstallationStoreAuthorize from slack_bolt.oauth import OAuthFlow from slack_sdk import WebClient from slack_sdk.oauth.installation_store.amazon_s3 import AmazonS3InstallationStore from slack_sdk.oauth.state_store.amazon_s3 import AmazonS3OAuthStateStore +from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.util.utils import create_web_client @@ -19,59 +21,53 @@ def __init__( *, client: Optional[WebClient] = None, logger: Optional[Logger] = None, - oauth_state_bucket_name: str = os.environ["SLACK_STATE_S3_BUCKET_NAME"], - installation_bucket_name: str = os.environ["SLACK_INSTALLATION_S3_BUCKET_NAME"], - oauth_state_cookie_name: str = "slack-app-oauth-state", - oauth_state_expiration_seconds: int = 60 * 10, # 10 minutes - client_id: str = os.environ["SLACK_CLIENT_ID"], - client_secret: str = os.environ["SLACK_CLIENT_SECRET"], - scopes: Optional[str] = os.environ.get("SLACK_SCOPES", None), - user_scopes: Optional[str] = os.environ.get("SLACK_USER_SCOPES", None), - redirect_uri: Optional[str] = os.environ.get("SLACK_REDIRECT_URI", None), - install_path: str = os.environ.get("SLACK_LAMBDA_PATH", "/slack/install"), - redirect_uri_path: str = os.environ.get( - "SLACK_LAMBDA_PATH", "/slack/oauth_redirect" - ), - success_url: Optional[str] = None, - failure_url: Optional[str] = None, + settings: Optional[OAuthSettings] = None, + oauth_state_bucket_name: Optional[str] = None, # required + installation_bucket_name: Optional[str] = None, # required ): - self._client = client - self._logger = logger - - self.s3_client = boto3.client("s3") - - self.oauth_state_store = AmazonS3OAuthStateStore( - logger=self.logger, - s3_client=self.s3_client, - bucket_name=oauth_state_bucket_name, - expiration_seconds=oauth_state_expiration_seconds, + logger = logger or logging.getLogger(__name__) + settings = settings or OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], ) - self.installation_store = AmazonS3InstallationStore( - logger=self.logger, - s3_client=self.s3_client, - bucket_name=installation_bucket_name, - client_id=client_id, - ) - self.oauth_state_cookie_name = oauth_state_cookie_name - self.oauth_state_expiration_seconds = oauth_state_expiration_seconds + oauth_state_bucket_name = oauth_state_bucket_name or os.environ["SLACK_STATE_S3_BUCKET_NAME"] + installation_bucket_name = installation_bucket_name or os.environ["SLACK_INSTALLATION_S3_BUCKET_NAME"] + self.s3_client = boto3.client("s3") + if settings.state_store is None or not isinstance(settings.state_store, AmazonS3OAuthStateStore): + settings.state_store = AmazonS3OAuthStateStore( + logger=logger, + s3_client=self.s3_client, + bucket_name=oauth_state_bucket_name, + expiration_seconds=settings.state_expiration_seconds, + ) - self.client_id = client_id - self.client_secret = client_secret - self.scopes = scopes.split(",") if scopes else None - self.user_scopes = user_scopes.split(",") if user_scopes else None - self.redirect_uri = redirect_uri + if settings.installation_store is None or not isinstance(settings.installation_store, AmazonS3InstallationStore): + settings.installation_store = AmazonS3InstallationStore( + logger=logger, + s3_client=self.s3_client, + bucket_name=installation_bucket_name, + client_id=settings.client_id, + ) - self.install_path = install_path - self.redirect_uri_path = redirect_uri_path - self.success_url = success_url - self.failure_url = failure_url + # Set up authorize function to surely use this installation_store. + # When a developer use a settings initialized outside this constructor, + # the settings may already have pre-defined authorize. + # In this case, the /slack/events endpoint doesn't work along with the OAuth flow. + settings.authorize = InstallationStoreAuthorize( + logger=logger, + client_id=settings.client_id, + client_secret=settings.client_secret, + installation_store=settings.installation_store, + bot_only=settings.installation_store_bot_only, + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), + ) - self._init_internal_utils() + OAuthFlow.__init__(self, client=client, logger=logger, settings=settings) @property def client(self) -> WebClient: if self._client is None: - self._client = create_web_client() + self._client = create_web_client(logger=self.logger) return self._client @property diff --git a/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py b/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py new file mode 100644 index 000000000..48703a96d --- /dev/null +++ b/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py @@ -0,0 +1,30 @@ +import json +from logging import Logger +from typing import Callable, Optional, Any + +import boto3 # type: ignore[import-untyped] + +from slack_bolt import BoltRequest +from slack_bolt.lazy_listener import LazyListenerRunner + + +class LambdaLazyListenerRunner(LazyListenerRunner): + def __init__(self, logger: Logger, lambda_client: Optional[Any] = None): + self.lambda_client = lambda_client + self.logger = logger + + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + if self.lambda_client is None: + self.lambda_client = boto3.client("lambda") + + event: dict = request.context["lambda_request"] + headers = event["headers"] + headers["x-slack-bolt-lazy-only"] = "1" # not an array + headers["x-slack-bolt-lazy-function-name"] = request.lazy_function_name # not an array + event["method"] = "NONE" + invocation = self.lambda_client.invoke( + FunctionName=request.context["aws_lambda_invoked_function_arn"], + InvocationType="Event", + Payload=json.dumps(event), + ) + self.logger.info(invocation) diff --git a/slack_bolt/adapter/aws_lambda/local_lambda_client.py b/slack_bolt/adapter/aws_lambda/local_lambda_client.py new file mode 100644 index 000000000..fdab90b81 --- /dev/null +++ b/slack_bolt/adapter/aws_lambda/local_lambda_client.py @@ -0,0 +1,26 @@ +import json + +from chalice.app import Chalice +from chalice.config import Config +from chalice.test import BaseClient, LambdaContext, InvokeResponse + + +class LocalLambdaClient(BaseClient): + """Lambda client implementing `invoke` for use when running with Chalice CLI.""" + + def __init__(self, app: Chalice, config: Config) -> None: + self._app = app + self._config = config if config else Config() + + def invoke( + self, + FunctionName: str, + InvocationType: str = "Event", + Payload: str = "{}", + ) -> InvokeResponse: + scoped = self._config.scope(self._config.chalice_stage, FunctionName) + lambda_context = LambdaContext(FunctionName, memory_size=scoped.lambda_memory_size) + + with self._patched_env_vars(scoped.environment_variables): + response = self._app(json.loads(Payload), lambda_context) + return InvokeResponse(payload=response) diff --git a/slack_bolt/adapter/bottle/__init__.py b/slack_bolt/adapter/bottle/__init__.py index f08c97a5f..83f4882db 100644 --- a/slack_bolt/adapter/bottle/__init__.py +++ b/slack_bolt/adapter/bottle/__init__.py @@ -1 +1,5 @@ from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/bottle/handler.py b/slack_bolt/adapter/bottle/handler.py index 1467cf35f..d99e24afa 100644 --- a/slack_bolt/adapter/bottle/handler.py +++ b/slack_bolt/adapter/bottle/handler.py @@ -1,4 +1,4 @@ -from bottle import Request, Response +from bottle import Request, Response # type: ignore[import-untyped] from slack_bolt.app import App from slack_bolt.oauth import OAuthFlow @@ -7,8 +7,13 @@ def to_bolt_request(req: Request) -> BoltRequest: + body = req.body.read() + if isinstance(body, bytes): + body = body.decode("utf-8") return BoltRequest( - body=req.body.read(), query=req.query_string, headers=req.headers, + body=body, + query=req.query_string, + headers=req.headers, ) @@ -20,7 +25,7 @@ def set_response(bolt_resp: BoltResponse, resp: Response) -> None: class SlackRequestHandler: - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app def handle(self, req: Request, resp: Response) -> str: @@ -36,7 +41,7 @@ def handle(self, req: Request, resp: Response) -> str: set_response(bolt_resp, resp) return bolt_resp.body or "" elif req.method == "POST": - bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + bolt_resp = self.app.dispatch(to_bolt_request(req)) set_response(bolt_resp, resp) return bolt_resp.body or "" diff --git a/slack_bolt/adapter/cherrypy/__init__.py b/slack_bolt/adapter/cherrypy/__init__.py new file mode 100644 index 000000000..83f4882db --- /dev/null +++ b/slack_bolt/adapter/cherrypy/__init__.py @@ -0,0 +1,5 @@ +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/cherrypy/handler.py b/slack_bolt/adapter/cherrypy/handler.py new file mode 100644 index 000000000..f3d714cc6 --- /dev/null +++ b/slack_bolt/adapter/cherrypy/handler.py @@ -0,0 +1,81 @@ +from typing import Optional + +import cherrypy # type: ignore[import-untyped] + +from slack_bolt.app import App +from slack_bolt.oauth import OAuthFlow +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def build_bolt_request() -> BoltRequest: + req = cherrypy.request + body = req.raw_body if hasattr(req, "raw_body") else "" + return BoltRequest( + body=body, + query=req.query_string, + headers=req.headers, + ) + + +def set_response_status_and_headers(bolt_resp: BoltResponse) -> None: + cherrypy.response.status = bolt_resp.status + for k, v in bolt_resp.first_headers_without_set_cookie().items(): + cherrypy.response.headers[k] = v + for cookie in bolt_resp.cookies(): + for name, c in cookie.items(): + str_max_age: Optional[str] = c.get("max-age") + max_age: Optional[int] = int(str_max_age) if str_max_age else None + cherrypy_cookie = cherrypy.response.cookie + cherrypy_cookie[name] = c.value + cherrypy_cookie[name]["expires"] = c.get("expires") + cherrypy_cookie[name]["max-age"] = max_age + cherrypy_cookie[name]["domain"] = c.get("domain") + cherrypy_cookie[name]["path"] = c.get("path") + cherrypy_cookie[name]["secure"] = True + cherrypy_cookie[name]["httponly"] = True + + +@cherrypy.tools.register("on_start_resource") +def slack_in(): + request = cherrypy.serving.request + + def slack_processor(entity): + try: + if request.process_request_body: + body = entity.fp.read() + body = body.decode("utf-8") if isinstance(body, bytes) else "" + request.raw_body = body + except ValueError: + raise cherrypy.HTTPError(400, "Invalid request body") + + request.body.processors.clear() + request.body.processors["application/json"] = slack_processor + request.body.processors["application/x-www-form-urlencoded"] = slack_processor + + +class SlackRequestHandler: + def __init__(self, app: App): + self.app = app + + def handle(self) -> bytes: + req = cherrypy.request + if req.method == "GET": + if self.app.oauth_flow is not None: + oauth_flow: OAuthFlow = self.app.oauth_flow + request_path = req.wsgi_environ["REQUEST_URI"].split("?")[0] + if request_path == oauth_flow.install_path: + bolt_resp = oauth_flow.handle_installation(build_bolt_request()) + set_response_status_and_headers(bolt_resp) + return (bolt_resp.body or "").encode("utf-8") + elif request_path == oauth_flow.redirect_uri_path: + bolt_resp = oauth_flow.handle_callback(build_bolt_request()) + set_response_status_and_headers(bolt_resp) + return (bolt_resp.body or "").encode("utf-8") + elif req.method == "POST": + bolt_resp = self.app.dispatch(build_bolt_request()) + set_response_status_and_headers(bolt_resp) + return (bolt_resp.body or "").encode("utf-8") + + cherrypy.response.status = 404 + return "Not Found".encode("utf-8") diff --git a/slack_bolt/adapter/django/__init__.py b/slack_bolt/adapter/django/__init__.py index f08c97a5f..83f4882db 100644 --- a/slack_bolt/adapter/django/__init__.py +++ b/slack_bolt/adapter/django/__init__.py @@ -1 +1,5 @@ from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/django/handler.py b/slack_bolt/adapter/django/handler.py index 58e42edcd..4ede5fde3 100644 --- a/slack_bolt/adapter/django/handler.py +++ b/slack_bolt/adapter/django/handler.py @@ -1,8 +1,23 @@ -from typing import Optional +import logging +from logging import Logger +from threading import current_thread, Thread +from typing import Optional, Callable -from django.http import HttpRequest, HttpResponse +from django.http import HttpRequest, HttpResponse # type: ignore[import-untyped] from slack_bolt.app import App +from slack_bolt.error import BoltError +from slack_bolt.lazy_listener import ThreadLazyListenerRunner +from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.listener.listener_start_handler import ( + ListenerStartHandler, + DefaultListenerStartHandler, +) +from slack_bolt.listener.listener_completion_handler import ( + ListenerCompletionHandler, + DefaultListenerCompletionHandler, +) +from slack_bolt.listener.thread_runner import ThreadListenerRunner from slack_bolt.oauth import OAuthFlow from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse @@ -11,36 +26,141 @@ def to_bolt_request(req: HttpRequest) -> BoltRequest: raw_body: bytes = req.body body: str = raw_body.decode("utf-8") if raw_body else "" - return BoltRequest(body=body, query=req.META["QUERY_STRING"], headers=req.headers,) + return BoltRequest( + body=body, + query=req.META["QUERY_STRING"], + headers=req.headers, + ) def to_django_response(bolt_resp: BoltResponse) -> HttpResponse: resp: HttpResponse = HttpResponse( - status=bolt_resp.status, content=bolt_resp.body.encode("utf-8"), + status=bolt_resp.status, + content=bolt_resp.body.encode("utf-8"), ) for k, v in bolt_resp.first_headers_without_set_cookie().items(): resp[k] = v for cookie in bolt_resp.cookies(): for name, c in cookie.items(): - str_max_age: Optional[str] = c.get("max-age", None) + str_max_age: Optional[str] = c.get("max-age") max_age: Optional[int] = int(str_max_age) if str_max_age else None resp.set_cookie( key=name, value=c.value, - expires=c.get("expires", None), + expires=c.get("expires"), max_age=max_age, - domain=c.get("domain", None), - path=c.get("path", None), + domain=c.get("domain"), + path=c.get("path"), secure=True, httponly=True, ) return resp +from django.db import close_old_connections # type: ignore[import-untyped] + + +def release_thread_local_connections(logger: Logger, execution_timing: str): + close_old_connections() + if logger.level <= logging.DEBUG: + current: Thread = current_thread() + logger.debug( + "Released thread-bound old DB connections " + f"(thread name: {current.name}, execution timing: {execution_timing})" + ) + + +class DjangoListenerStartHandler(ListenerStartHandler): + """Django sets DB connections as a thread-local variable per thread. + If the thread is not managed on the Django app side, the connections won't be released by Django. + This handler releases the connections every time a ThreadListenerRunner execution completes. + """ + + def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: + release_thread_local_connections(request.context.logger, "listener-start") + + +class DjangoListenerCompletionHandler(ListenerCompletionHandler): + """Django sets DB connections as a thread-local variable per thread. + If the thread is not managed on the Django app side, the connections won't be released by Django. + This handler releases the connections every time a ThreadListenerRunner execution completes. + """ + + def handle(self, request: BoltRequest, response: Optional[BoltResponse]) -> None: + release_thread_local_connections(request.context.logger, "listener-completion") + + +class DjangoThreadLazyListenerRunner(ThreadLazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + func: Callable[[], None] = build_runnable_function( + func=function, + logger=self.logger, + request=request, + ) + + def wrapped_func(): + release_thread_local_connections(request.context.logger, "before-lazy-listener") + try: + func() + finally: + release_thread_local_connections(request.context.logger, "lazy-listener-completion") + + self.executor.submit(wrapped_func) + + class SlackRequestHandler: - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app + listener_runner = self.app.listener_runner + # This runner closes all thread-local connections in the thread when an execution completes + self.app.listener_runner.lazy_listener_runner = DjangoThreadLazyListenerRunner( + logger=listener_runner.logger, + executor=listener_runner.listener_executor, + ) + + if not isinstance(listener_runner, ThreadListenerRunner): + raise BoltError("Custom listener_runners are not compatible with this Django adapter.") + + if app.process_before_response is True: + # As long as the app access Django models in the same thread, + # Django cleans the connections up for you. + self.app.logger.debug("App.process_before_response is set to True") + return + + current_start_handler = listener_runner.listener_start_handler + if current_start_handler is not None and not isinstance(current_start_handler, DefaultListenerStartHandler): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. + message = """As you've already set app.listener_runner.listener_start_handler to your own one, + Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerStartHandler. + + If you go with your own handler here, we highly recommend having the following lines of code + in your handle() method to clean up unmanaged stale/old database connections: + + from django.db import close_old_connections + close_old_connections() + """ + self.app.logger.info(message) + else: + # for proper management of thread-local Django DB connections + self.app.listener_runner.listener_start_handler = DjangoListenerStartHandler() + self.app.logger.debug("DjangoListenerStartHandler has been enabled") + + current_completion_handler = listener_runner.listener_completion_handler + if current_completion_handler is not None and not isinstance( + current_completion_handler, DefaultListenerCompletionHandler + ): + # As we run release_thread_local_connections() before listener executions, + # it's okay to skip calling the same connection clean-up method at the listener completion. + message = """As you've already set app.listener_runner.listener_completion_handler to your own one, + Bolt skipped to set it to slack_sdk.adapter.django.DjangoListenerCompletionHandler. + """ + self.app.logger.info(message) + return + # for proper management of thread-local Django DB connections + self.app.listener_runner.listener_completion_handler = DjangoListenerCompletionHandler() + self.app.logger.debug("DjangoListenerCompletionHandler has been enabled") def handle(self, req: HttpRequest) -> HttpResponse: if req.method == "GET": @@ -53,7 +173,7 @@ def handle(self, req: HttpRequest) -> HttpResponse: bolt_resp = oauth_flow.handle_callback(to_bolt_request(req)) return to_django_response(bolt_resp) elif req.method == "POST": - bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + bolt_resp = self.app.dispatch(to_bolt_request(req)) return to_django_response(bolt_resp) return HttpResponse(status=404, content=b"Not Found") diff --git a/slack_bolt/adapter/falcon/__init__.py b/slack_bolt/adapter/falcon/__init__.py index 01dcc736f..4efa37a4b 100644 --- a/slack_bolt/adapter/falcon/__init__.py +++ b/slack_bolt/adapter/falcon/__init__.py @@ -1 +1,6 @@ +# Don't add async module imports here from .resource import SlackAppResource + +__all__ = [ + "SlackAppResource", +] diff --git a/slack_bolt/adapter/falcon/async_resource.py b/slack_bolt/adapter/falcon/async_resource.py new file mode 100644 index 000000000..fdb2d975f --- /dev/null +++ b/slack_bolt/adapter/falcon/async_resource.py @@ -0,0 +1,78 @@ +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 +from slack_bolt.async_app import AsyncApp +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.request.async_request import AsyncBoltRequest + + +class AsyncSlackAppResource: + """ + For use with ASGI Falcon Apps. + + from slack_bolt.async_app import AsyncApp + app = AsyncApp() + + import falcon + app = falcon.asgi.App() + app.add_route("/slack/events", AsyncSlackAppResource(app)) + """ + + def __init__(self, app: AsyncApp): + if falcon_version.__version__.startswith("2."): + raise BoltError("This ASGI compatible adapter requires Falcon version >= 3.0") + + self.app = app + + async def on_get(self, req: Request, resp: Response): + if self.app.oauth_flow is not None: + oauth_flow: AsyncOAuthFlow = self.app.oauth_flow + if req.path == oauth_flow.install_path: + bolt_resp = await oauth_flow.handle_installation(await self._to_bolt_request(req)) + await self._write_response(bolt_resp, resp) + return + elif req.path == oauth_flow.redirect_uri_path: + bolt_resp = await oauth_flow.handle_callback(await self._to_bolt_request(req)) + await self._write_response(bolt_resp, resp) + return + + resp.status = 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) + bolt_resp = await self.app.async_dispatch(bolt_req) + await self._write_response(bolt_resp, resp) + + async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest: + return AsyncBoltRequest( + body=(await req.stream.read(req.content_length or 0)).decode("utf-8"), + query=req.query_string, + headers={k.lower(): v for k, v in req.headers.items()}, + ) + + async def _write_response(self, bolt_resp: BoltResponse, resp: Response): + resp.text = bolt_resp.body + status = HTTPStatus(bolt_resp.status) + resp.status = str(f"{status.value} {status.phrase}") + resp.set_headers(bolt_resp.first_headers_without_set_cookie()) + for cookie in bolt_resp.cookies(): + for name, c in cookie.items(): + expire_value = c.get("expires") + expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None + resp.set_cookie( + name=name, + value=c.value, + expires=expire, + max_age=c.get("max-age"), + domain=c.get("domain"), + path=c.get("path"), + secure=True, + http_only=True, + ) diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index 2ba4481d5..5d162ad23 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -1,6 +1,8 @@ from datetime import datetime +from http import HTTPStatus -from falcon import Request, Response +from falcon import MEDIA_TEXT +from falcon import Request, Response, version as falcon_version from slack_bolt import BoltResponse from slack_bolt.app import App @@ -18,7 +20,7 @@ class SlackAppResource: api.add_route("/slack/events", SlackAppResource(app)) """ - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app def on_get(self, req: Request, resp: Response): @@ -33,8 +35,9 @@ def on_get(self, req: Request, resp: Response): self._write_response(bolt_resp, resp) return - resp.status = "404" - 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) @@ -49,24 +52,26 @@ def _to_bolt_request(self, req: Request) -> BoltRequest: ) def _write_response(self, bolt_resp: BoltResponse, resp: Response): - resp.body = bolt_resp.body - resp.status = str(bolt_resp.status) + if falcon_version.__version__.startswith("2."): + # Falcon 4.x w/ mypy fails to correctly infer the str type here + resp.body = bolt_resp.body + else: + resp.text = bolt_resp.body + + status = HTTPStatus(bolt_resp.status) + resp.status = str(f"{status.value} {status.phrase}") resp.set_headers(bolt_resp.first_headers_without_set_cookie()) for cookie in bolt_resp.cookies(): for name, c in cookie.items(): - expire_value = c.get("expires", None) - expire = ( - datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") - if expire_value - else None - ) + expire_value = c.get("expires") + expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None resp.set_cookie( name=name, value=c.value, expires=expire, - max_age=c.get("max-age", None), - domain=c.get("domain", None), - path=c.get("path", None), + max_age=c.get("max-age"), + domain=c.get("domain"), + path=c.get("path"), secure=True, http_only=True, ) diff --git a/slack_bolt/adapter/fastapi/__init__.py b/slack_bolt/adapter/fastapi/__init__.py index 8342fbb03..224fd5fa2 100644 --- a/slack_bolt/adapter/fastapi/__init__.py +++ b/slack_bolt/adapter/fastapi/__init__.py @@ -1,2 +1,6 @@ -from ..starlette.async_handler import AsyncSlackRequestHandler # noqa -from ..starlette.handler import SlackRequestHandler # noqa +# Don't add async module imports here +from ..starlette.handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/fastapi/async_handler.py b/slack_bolt/adapter/fastapi/async_handler.py new file mode 100644 index 000000000..f2c149c6d --- /dev/null +++ b/slack_bolt/adapter/fastapi/async_handler.py @@ -0,0 +1,5 @@ +from ..starlette.async_handler import AsyncSlackRequestHandler + +__all__ = [ + "AsyncSlackRequestHandler", +] diff --git a/slack_bolt/adapter/flask/__init__.py b/slack_bolt/adapter/flask/__init__.py index f08c97a5f..83f4882db 100644 --- a/slack_bolt/adapter/flask/__init__.py +++ b/slack_bolt/adapter/flask/__init__.py @@ -1 +1,5 @@ from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/flask/handler.py b/slack_bolt/adapter/flask/handler.py index fb7d44ce1..c88c4d2e5 100644 --- a/slack_bolt/adapter/flask/handler.py +++ b/slack_bolt/adapter/flask/handler.py @@ -7,23 +7,26 @@ def to_bolt_request(req: Request) -> BoltRequest: - return BoltRequest( # type: ignore + return BoltRequest( body=req.get_data(as_text=True), query=req.query_string.decode("utf-8"), - headers=req.headers, # type: ignore - ) # type: ignore + headers=req.headers, # type: ignore[arg-type] + ) def to_flask_response(bolt_resp: BoltResponse) -> Response: resp: Response = make_response(bolt_resp.body, bolt_resp.status) for k, values in bolt_resp.headers.items(): + if k.lower() == "content-type" and resp.headers.get("content-type") is not None: + # Remove the one set by Flask + resp.headers.pop("content-type") for v in values: resp.headers.add_header(k, v) return resp class SlackRequestHandler: - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app def handle(self, req: Request) -> Response: @@ -37,7 +40,7 @@ def handle(self, req: Request) -> Response: bolt_resp = oauth_flow.handle_callback(to_bolt_request(req)) return to_flask_response(bolt_resp) elif req.method == "POST": - bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req)) + bolt_resp = self.app.dispatch(to_bolt_request(req)) return to_flask_response(bolt_resp) return make_response("Not Found", 404) diff --git a/slack_bolt/adapter/google_cloud_functions/__init__.py b/slack_bolt/adapter/google_cloud_functions/__init__.py new file mode 100644 index 000000000..83f4882db --- /dev/null +++ b/slack_bolt/adapter/google_cloud_functions/__init__.py @@ -0,0 +1,5 @@ +from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/google_cloud_functions/handler.py b/slack_bolt/adapter/google_cloud_functions/handler.py new file mode 100644 index 000000000..fdc62a41f --- /dev/null +++ b/slack_bolt/adapter/google_cloud_functions/handler.py @@ -0,0 +1,42 @@ +from typing import Callable + +from flask import Request, Response, make_response + +from slack_bolt.adapter.flask.handler import to_bolt_request, to_flask_response +from slack_bolt.app import App +from slack_bolt.error import BoltError +from slack_bolt.lazy_listener import LazyListenerRunner +from slack_bolt.request import BoltRequest + + +class NoopLazyListenerRunner(LazyListenerRunner): + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + raise BoltError( + "The google_cloud_functions adapter does not support lazy listeners. " + "Please consider either having a queue to pass the request to a different function or " + "rewriting your code not to use lazy listeners." + ) + + +class SlackRequestHandler: + def __init__(self, app: App): + self.app = app + # Note that lazy listener is not supported + self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner() + if self.app.oauth_flow is not None: + self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?" + + def handle(self, req: Request) -> Response: + if req.method == "GET" and self.app.oauth_flow is not None: + bolt_req = to_bolt_request(req) + if "code" in req.args or "error" in req.args or "state" in req.args: + bolt_resp = self.app.oauth_flow.handle_callback(bolt_req) + return to_flask_response(bolt_resp) + else: + bolt_resp = self.app.oauth_flow.handle_installation(bolt_req) + return to_flask_response(bolt_resp) + elif req.method == "POST": + bolt_resp = self.app.dispatch(to_bolt_request(req)) + return to_flask_response(bolt_resp) + + return make_response("Not Found", 404) diff --git a/slack_bolt/adapter/pyramid/__init__.py b/slack_bolt/adapter/pyramid/__init__.py index f08c97a5f..83f4882db 100644 --- a/slack_bolt/adapter/pyramid/__init__.py +++ b/slack_bolt/adapter/pyramid/__init__.py @@ -1 +1,5 @@ from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/pyramid/handler.py b/slack_bolt/adapter/pyramid/handler.py index 47512e4cd..0dea272ac 100644 --- a/slack_bolt/adapter/pyramid/handler.py +++ b/slack_bolt/adapter/pyramid/handler.py @@ -1,15 +1,21 @@ from typing import List, Tuple -from pyramid.request import Request -from pyramid.response import Response +from pyramid.request import Request # type: ignore[import-untyped] +from pyramid.response import Response # type: ignore[import-untyped] from slack_bolt import App, BoltRequest, BoltResponse from slack_bolt.oauth import OAuthFlow def to_bolt_request(request: Request) -> BoltRequest: + body: str = "" + if request.body is not None: + if isinstance(request.body, bytes): + body = request.body.decode("utf-8") + else: + body = request.body bolt_req = BoltRequest( - body=request.body.decode("utf-8"), + body=body, query=request.query_string, headers=request.headers, ) @@ -31,7 +37,7 @@ def to_pyramid_response(bolt_resp: BoltResponse) -> Response: class SlackRequestHandler: - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app def handle(self, request: Request) -> Response: @@ -39,14 +45,34 @@ def handle(self, request: Request) -> Response: if self.app.oauth_flow is not None: oauth_flow: OAuthFlow = self.app.oauth_flow if request.path == oauth_flow.install_path: - bolt_resp = oauth_flow.handle_installation(to_bolt_request(request)) + bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request) + bolt_resp = oauth_flow.handle_installation(bolt_req) return to_pyramid_response(bolt_resp) elif request.path == oauth_flow.redirect_uri_path: - bolt_resp = oauth_flow.handle_callback(to_bolt_request(request)) + bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request) + bolt_resp = oauth_flow.handle_callback(bolt_req) return to_pyramid_response(bolt_resp) elif request.method == "POST": - bolt_req = to_bolt_request(request) + bolt_req = _attach_pyramid_request_to_context(to_bolt_request(request), request) bolt_resp = self.app.dispatch(bolt_req) return to_pyramid_response(bolt_resp) return Response(status=404, body="Not found") + + +def _attach_pyramid_request_to_context( + bolt_req: BoltRequest, + request: Request, +) -> BoltRequest: + # To enable developers to access request-scope attributes such as dbsession, + # this adapter exposes the underlying pyramid_request object to Bolt listeners + # + # Developers can access request props this way: + # @app.event("app_mention") + # def handle_app_mention_events(context, logger): + # req = context["pyramid_request"] + # all = req.dbsession.query(MyModel).all() + # logger.info(all) + # + bolt_req.context["pyramid_request"] = request + return bolt_req diff --git a/slack_bolt/adapter/sanic/__init__.py b/slack_bolt/adapter/sanic/__init__.py index cf7d5ec69..3805e26ed 100644 --- a/slack_bolt/adapter/sanic/__init__.py +++ b/slack_bolt/adapter/sanic/__init__.py @@ -1 +1,5 @@ from .async_handler import AsyncSlackRequestHandler + +__all__ = [ + "AsyncSlackRequestHandler", +] diff --git a/slack_bolt/adapter/sanic/async_handler.py b/slack_bolt/adapter/sanic/async_handler.py index d123dd9d9..4b01d1e58 100644 --- a/slack_bolt/adapter/sanic/async_handler.py +++ b/slack_bolt/adapter/sanic/async_handler.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any, Dict, Optional from sanic.request import Request from sanic.response import HTTPResponse @@ -8,11 +9,19 @@ from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow -def to_async_bolt_request(req: Request) -> AsyncBoltRequest: - return AsyncBoltRequest( - body=req.body.decode("utf-8"), query=req.query_string, headers=req.headers, +def to_async_bolt_request(req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> AsyncBoltRequest: + request = AsyncBoltRequest( + body=req.body.decode("utf-8"), + query=req.query_string, + headers=req.headers, # type: ignore[arg-type] ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + + return request + def to_sanic_response(bolt_resp: BoltResponse) -> HTTPResponse: resp = HTTPResponse( @@ -20,42 +29,48 @@ def to_sanic_response(bolt_resp: BoltResponse) -> HTTPResponse: body=bolt_resp.body, headers=bolt_resp.first_headers_without_set_cookie(), ) + for cookie in bolt_resp.cookies(): - for name, c in cookie.items(): - resp.cookies[name] = c.value - expire_value = c.get("expires", None) - if expire_value is not None and expire_value != "": - expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") - resp.cookies[name]["expires"] = expire - resp.cookies[name]["path"] = c.get("path", None) - resp.cookies[name]["domain"] = c.get("domain", None) - resp.cookies[name]["max-age"] = c.get("max-age", None) - resp.cookies[name]["secure"] = True - resp.cookies[name]["httponly"] = True + for key, c in cookie.items(): + expire_value = c.get("expires") + expires = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None + max_age = int(c["max-age"]) if c.get("max-age") else None + path = str(c.get("path")) if c.get("path") else "/" + domain = str(c.get("domain")) if c.get("domain") else None + resp.add_cookie( + key=key, + value=c.value, + expires=expires, + path=path, + domain=domain, + max_age=max_age, + secure=True, + httponly=True, + ) + return resp class AsyncSlackRequestHandler: - def __init__(self, app: AsyncApp): # type: ignore + def __init__(self, app: AsyncApp): self.app = app - async def handle(self, req: Request) -> HTTPResponse: + async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> HTTPResponse: if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: AsyncOAuthFlow = self.app.oauth_flow if req.path == oauth_flow.install_path: - bolt_resp = await oauth_flow.handle_installation( - to_async_bolt_request(req) - ) + bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(req, addition_context_properties)) return to_sanic_response(bolt_resp) elif req.path == oauth_flow.redirect_uri_path: - bolt_resp = await oauth_flow.handle_callback( - to_async_bolt_request(req) - ) + bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(req, addition_context_properties)) return to_sanic_response(bolt_resp) elif req.method == "POST": - bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req)) + bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, addition_context_properties)) return to_sanic_response(bolt_resp) - return HTTPResponse(status=404, body="Not found",) + return HTTPResponse( + status=404, + body="Not found", + ) diff --git a/slack_bolt/adapter/socket_mode/__init__.py b/slack_bolt/adapter/socket_mode/__init__.py new file mode 100644 index 000000000..681f5b2a8 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/__init__.py @@ -0,0 +1,14 @@ +"""Socket Mode adapter package provides the following implementations. If you don't have strong reasons to use 3rd party library based adapters, we recommend using the built-in client based one. + +* `slack_bolt.adapter.socket_mode.builtin` +* `slack_bolt.adapter.socket_mode.websocket_client` +* `slack_bolt.adapter.socket_mode.aiohttp` +* `slack_bolt.adapter.socket_mode.websockets` +""" # noqa: E501 + +# Don't add async module imports here +from .builtin import SocketModeHandler + +__all__ = [ + "SocketModeHandler", +] diff --git a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py new file mode 100644 index 000000000..124daaa4a --- /dev/null +++ b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py @@ -0,0 +1,95 @@ +"""[`aiohttp`](https://pypi.org/project/aiohttp/) based implementation / asyncio compatible""" + +import os +from logging import Logger +from time import time +from typing import Optional +from asyncio import AbstractEventLoop + +from slack_sdk.socket_mode.aiohttp import SocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.async_base_handler import AsyncBaseSocketModeHandler +from slack_bolt.adapter.socket_mode.async_internals import ( + send_async_response, + run_async_bolt_app, +) +from slack_bolt.adapter.socket_mode.internals import run_bolt_app +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(AsyncBaseSocketModeHandler): + app: App + app_token: str + client: SocketModeClient + + def __init__( + self, + app: App, + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + proxy: Optional[str] = None, + ping_interval: float = 10, + ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + proxy: HTTP proxy URL + ping_interval: The ping-pong internal (seconds) + """ + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, # type: ignore[arg-type] + proxy=proxy, + ping_interval=ping_interval, + ) + self.client.socket_mode_request_listeners.append(self.handle) # type: ignore[arg-type] + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: # type: ignore[override] + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) + + +class AsyncSocketModeHandler(AsyncBaseSocketModeHandler): + app: AsyncApp + app_token: str + client: SocketModeClient + + def __init__( + self, + app: AsyncApp, + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + proxy: Optional[str] = None, + ping_interval: float = 10, + loop: Optional[AbstractEventLoop] = None, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + proxy=proxy, + ping_interval=ping_interval, + loop=loop, + ) + self.client.socket_mode_request_listeners.append(self.handle) # type: ignore[arg-type] + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: # type: ignore[override] + start = time() + bolt_resp: BoltResponse = await run_async_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) diff --git a/slack_bolt/adapter/socket_mode/async_base_handler.py b/slack_bolt/adapter/socket_mode/async_base_handler.py new file mode 100644 index 000000000..32ddaff14 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_base_handler.py @@ -0,0 +1,50 @@ +"""The base class of asyncio-based Socket Mode client implementation""" + +import asyncio +import logging +from typing import Union + +from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest + +from slack_bolt import App +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.util.utils import get_boot_message + + +class AsyncBaseSocketModeHandler: + app: Union[App, AsyncApp] + client: AsyncBaseSocketModeClient + + async def handle(self, client: AsyncBaseSocketModeClient, req: SocketModeRequest) -> None: + """Handles Socket Mode envelope requests through a WebSocket connection. + + Args: + client: this Socket Mode client instance + req: the request data + """ + raise NotImplementedError() + + async def connect_async(self): + """Establishes a new connection with the Socket Mode server""" + await self.client.connect() + + async def disconnect_async(self): + """Disconnects the current WebSocket connection with the Socket Mode server""" + await self.client.disconnect() + + async def close_async(self): + """Disconnects from the Socket Mode server and cleans the resources this instance holds up""" + await self.client.close() + + async def start_async(self): + """Establishes a new connection and then starts infinite sleep + to prevent the termination of this process. + If you don't want to have the sleep, use `#connect()` method instead. + """ + await self.connect_async() + if self.app.logger.level > logging.INFO: + print(get_boot_message()) + else: + self.app.logger.info(get_boot_message()) + await asyncio.sleep(float("inf")) diff --git a/slack_bolt/adapter/socket_mode/async_handler.py b/slack_bolt/adapter/socket_mode/async_handler.py new file mode 100644 index 000000000..09e3ea433 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_handler.py @@ -0,0 +1,7 @@ +"""Default implementation is the aiohttp-based one.""" + +from .aiohttp import AsyncSocketModeHandler + +__all__ = [ + "AsyncSocketModeHandler", +] diff --git a/slack_bolt/adapter/socket_mode/async_internals.py b/slack_bolt/adapter/socket_mode/async_internals.py new file mode 100644 index 000000000..428ab437c --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -0,0 +1,47 @@ +"""Internal functions""" + +import json +import logging +from time import time + +from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse + +from slack_bolt.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, headers=build_headers(req)) + bolt_resp: BoltResponse = await app.async_dispatch(bolt_req) + return bolt_resp + + +async def send_async_response( + client: AsyncBaseSocketModeClient, + req: SocketModeRequest, + bolt_resp: BoltResponse, + start_time: float, +): + if bolt_resp.status == 200: + content_type = bolt_resp.headers.get("content-type", [""])[0] + if bolt_resp.body is None or len(bolt_resp.body) == 0: + await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) + elif content_type.startswith("application/json"): + dict_body = json.loads(bolt_resp.body) + await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body)) + else: + await client.send_socket_mode_response( + SocketModeResponse( + envelope_id=req.envelope_id, + payload={"text": bolt_resp.body}, + ) + ) + if client.logger.level <= logging.DEBUG: + spent_time = int((time() - start_time) * 1000) + client.logger.debug(f"Response time: {spent_time} milliseconds") + else: + client.logger.info(f"Unsuccessful Bolt execution result (status: {bolt_resp.status}, body: {bolt_resp.body})") diff --git a/slack_bolt/adapter/socket_mode/base_handler.py b/slack_bolt/adapter/socket_mode/base_handler.py new file mode 100644 index 000000000..432a327b9 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/base_handler.py @@ -0,0 +1,58 @@ +"""The base class of Socket Mode client implementation. +If you want to build asyncio-based ones, use `AsyncBaseSocketModeHandler` instead. +""" + +import logging +import signal +import sys +from threading import Event + +from slack_sdk.socket_mode.client import BaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest + +from slack_bolt import App +from slack_bolt.util.utils import get_boot_message + + +class BaseSocketModeHandler: + app: App + client: BaseSocketModeClient + + def handle(self, client: BaseSocketModeClient, req: SocketModeRequest) -> None: + """Handles Socket Mode envelope requests through a WebSocket connection. + + Args: + client: this Socket Mode client instance + req: the request data + """ + raise NotImplementedError() + + def connect(self): + """Establishes a new connection with the Socket Mode server""" + self.client.connect() + + def disconnect(self): + """Disconnects the current WebSocket connection with the Socket Mode server""" + self.client.disconnect() + + def close(self): + """Disconnects from the Socket Mode server and cleans the resources this instance holds up""" + self.client.close() + + def start(self): + """Establishes a new connection and then blocks the current thread + to prevent the termination of this process. + If you don't want to block the current thread, use `#connect()` method instead. + """ + self.connect() + if self.app.logger.level > logging.INFO: + print(get_boot_message()) + else: + self.app.logger.info(get_boot_message()) + + if sys.platform == "win32": + # Ctrl+C etc does not work on Windows OS + # see https://bugs.python.org/issue35935 for details + signal.signal(signal.SIGINT, signal.SIG_DFL) + + Event().wait() diff --git a/slack_bolt/adapter/socket_mode/builtin/__init__.py b/slack_bolt/adapter/socket_mode/builtin/__init__.py new file mode 100644 index 000000000..6dbc9562d --- /dev/null +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -0,0 +1,77 @@ +"""The built-in implementation, which does not have any external dependencies""" + +import os +from logging import Logger +from time import time +from typing import Optional, Dict + +from slack_sdk import WebClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.builtin import SocketModeClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.base_handler import BaseSocketModeHandler +from slack_bolt.adapter.socket_mode.internals import run_bolt_app, send_response +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(BaseSocketModeHandler): + app: App + app_token: str + client: SocketModeClient + + def __init__( + self, + app: App, + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[WebClient] = None, + proxy: Optional[str] = None, + proxy_headers: Optional[Dict[str, str]] = None, + auto_reconnect_enabled: bool = True, + trace_enabled: bool = False, + all_message_trace_enabled: bool = False, + ping_pong_trace_enabled: bool = False, + ping_interval: float = 10, + receive_buffer_size: int = 1024, + concurrency: int = 10, + ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + proxy: HTTP proxy URL + proxy_headers: Additional request header for proxy connections + auto_reconnect_enabled: True if the auto-reconnect logic works + trace_enabled: True if trace-level logging is enabled + all_message_trace_enabled: True if trace-logging for all received WebSocket messages is enabled + ping_pong_trace_enabled: True if trace-logging for all ping-pong communications + ping_interval: The ping-pong internal (seconds) + receive_buffer_size: The data length for a single socket recv operation + concurrency: The size of the underlying thread pool + """ + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + proxy=proxy if proxy is not None else app.client.proxy, + proxy_headers=proxy_headers, + auto_reconnect_enabled=auto_reconnect_enabled, + trace_enabled=trace_enabled, + all_message_trace_enabled=all_message_trace_enabled, + ping_pong_trace_enabled=ping_pong_trace_enabled, + ping_interval=ping_interval, + receive_buffer_size=receive_buffer_size, + concurrency=concurrency, + ) + self.client.socket_mode_request_listeners.append(self.handle) # type: ignore[arg-type] + + def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: # type: ignore[override] + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + send_response(client, req, bolt_resp, start) diff --git a/slack_bolt/adapter/socket_mode/internals.py b/slack_bolt/adapter/socket_mode/internals.py new file mode 100644 index 000000000..6289f28f5 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/internals.py @@ -0,0 +1,55 @@ +"""Internal functions""" + +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 +from slack_sdk.socket_mode.response import SocketModeResponse + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def 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, headers=build_headers(req)) + bolt_resp: BoltResponse = app.dispatch(bolt_req) + return bolt_resp + + +def send_response( + client: BaseSocketModeClient, + req: SocketModeRequest, + bolt_resp: BoltResponse, + start_time: float, +): + if bolt_resp.status == 200: + content_type = bolt_resp.headers.get("content-type", [""])[0] + if bolt_resp.body is None or len(bolt_resp.body) == 0: + client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) + elif content_type.startswith("application/json"): + dict_body = json.loads(bolt_resp.body) + client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body)) + else: + client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id, payload={"text": bolt_resp.body}) + ) + + if client.logger.level <= logging.DEBUG: + spent_time = int((time() - start_time) * 1000) + client.logger.debug(f"Response time: {spent_time} milliseconds") + else: + client.logger.info(f"Unsuccessful Bolt execution result (status: {bolt_resp.status}, body: {bolt_resp.body})") diff --git a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py new file mode 100644 index 000000000..aae549ad6 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py @@ -0,0 +1,71 @@ +"""[`websocket-client`](https://pypi.org/project/websocket-client/) based implementation""" + +import os +from logging import Logger +from time import time +from typing import Optional, Tuple + +from slack_sdk import WebClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.websocket_client import SocketModeClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.base_handler import BaseSocketModeHandler +from slack_bolt.adapter.socket_mode.internals import run_bolt_app, send_response +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(BaseSocketModeHandler): + app: App + app_token: str + client: SocketModeClient + + def __init__( + self, + app: App, + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[WebClient] = None, + ping_interval: float = 10, + concurrency: int = 10, + http_proxy_host: Optional[str] = None, + http_proxy_port: Optional[int] = None, + http_proxy_auth: Optional[Tuple[str, str]] = None, + proxy_type: Optional[str] = None, + trace_enabled: bool = False, + ): + """Socket Mode adapter for Bolt apps + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + ping_interval: The ping-pong internal (seconds) + concurrency: The size of the underlying thread pool + http_proxy_host: HTTP proxy host + http_proxy_port: HTTP proxy port + http_proxy_auth: HTTP proxy authentication (username, password) + proxy_type: Proxy type + trace_enabled: True if trace-level logging is enabled + """ + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + ping_interval=ping_interval, + concurrency=concurrency, + http_proxy_host=http_proxy_host, + http_proxy_port=http_proxy_port, + http_proxy_auth=http_proxy_auth, + proxy_type=proxy_type, + trace_enabled=trace_enabled, + ) + self.client.socket_mode_request_listeners.append(self.handle) # type: ignore[arg-type] + + def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: # type: ignore[override] + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + send_response(client, req, bolt_resp, start) diff --git a/slack_bolt/adapter/socket_mode/websockets/__init__.py b/slack_bolt/adapter/socket_mode/websockets/__init__.py new file mode 100644 index 000000000..049a20570 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/websockets/__init__.py @@ -0,0 +1,91 @@ +"""[`websockets`](https://pypi.org/project/websockets/) based implementation / asyncio compatible""" + +import os +from logging import Logger +from time import time +from typing import Optional + +from slack_sdk.socket_mode.websockets import SocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.async_base_handler import AsyncBaseSocketModeHandler +from slack_bolt.adapter.socket_mode.async_internals import ( + send_async_response, + run_async_bolt_app, +) +from slack_bolt.adapter.socket_mode.internals import run_bolt_app +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(AsyncBaseSocketModeHandler): + app: App + app_token: str + client: SocketModeClient + + def __init__( + self, + app: App, + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + ping_interval: float = 10, + ): + """Socket Mode adapter for Bolt apps. + + Please note that this adapter does not support proxy configuration + as the underlying websockets module does not support proxy-wired connections. + If you use proxy, consider using one of the other Socket Mode adapters. + + Args: + app: The Bolt app + app_token: App-level token starting with `xapp-` + logger: Custom logger + web_client: custom `slack_sdk.web.WebClient` instance + ping_interval: The ping-pong internal (seconds) + """ + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, # type: ignore[arg-type] + ping_interval=ping_interval, + ) + self.client.socket_mode_request_listeners.append(self.handle) # type: ignore[arg-type] + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: # type: ignore[override] + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) + + +class AsyncSocketModeHandler(AsyncBaseSocketModeHandler): + app: AsyncApp + app_token: str + client: SocketModeClient + + def __init__( + self, + app: AsyncApp, + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + ping_interval: float = 10, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + ping_interval=ping_interval, + ) + self.client.socket_mode_request_listeners.append(self.handle) # type: ignore[arg-type] + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: # type: ignore[override] + start = time() + bolt_resp: BoltResponse = await run_async_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) diff --git a/slack_bolt/adapter/starlette/__init__.py b/slack_bolt/adapter/starlette/__init__.py index 118f4bab1..ed44db04c 100644 --- a/slack_bolt/adapter/starlette/__init__.py +++ b/slack_bolt/adapter/starlette/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/starlette/async_handler.py b/slack_bolt/adapter/starlette/async_handler.py index 4262d51d5..6e3305ac5 100644 --- a/slack_bolt/adapter/starlette/async_handler.py +++ b/slack_bolt/adapter/starlette/async_handler.py @@ -1,3 +1,5 @@ +from typing import Dict, Any, Optional + from starlette.requests import Request from starlette.responses import Response @@ -6,10 +8,20 @@ from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow -def to_async_bolt_request(req: Request, body: bytes) -> AsyncBoltRequest: - return AsyncBoltRequest( - body=body.decode("utf-8"), query=req.query_params, headers=req.headers, +def to_async_bolt_request( + req: Request, + body: bytes, + addition_context_properties: Optional[Dict[str, Any]] = None, +) -> AsyncBoltRequest: + request = AsyncBoltRequest( + body=body.decode("utf-8"), + query=req.query_params, # type: ignore[arg-type] + headers=req.headers, # type: ignore[arg-type] ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + return request def to_starlette_response(bolt_resp: BoltResponse) -> Response: @@ -23,10 +35,10 @@ def to_starlette_response(bolt_resp: BoltResponse) -> Response: resp.set_cookie( key=name, value=c.value, - max_age=c.get("max-age", None), - expires=c.get("expires", None), - path=c.get("path", None), - domain=c.get("domain", None), + max_age=c.get("max-age"), + expires=c.get("expires"), + path=c.get("path"), + domain=c.get("domain"), secure=True, httponly=True, ) @@ -34,26 +46,29 @@ def to_starlette_response(bolt_resp: BoltResponse) -> Response: class AsyncSlackRequestHandler: - def __init__(self, app: AsyncApp): # type: ignore + def __init__(self, app: AsyncApp): self.app = app - async def handle(self, req: Request) -> Response: + async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: AsyncOAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: bolt_resp = await oauth_flow.handle_installation( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: bolt_resp = await oauth_flow.handle_callback( - to_async_bolt_request(req, body) + to_async_bolt_request(req, body, addition_context_properties) ) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body)) + bolt_resp = await self.app.async_dispatch(to_async_bolt_request(req, body, addition_context_properties)) return to_starlette_response(bolt_resp) - return Response(status_code=404, content="Not found",) + return Response( + status_code=404, + content="Not found", + ) diff --git a/slack_bolt/adapter/starlette/handler.py b/slack_bolt/adapter/starlette/handler.py index 23cd2887f..7321eb86f 100644 --- a/slack_bolt/adapter/starlette/handler.py +++ b/slack_bolt/adapter/starlette/handler.py @@ -1,3 +1,5 @@ +from typing import Dict, Any, Optional + from starlette.requests import Request from starlette.responses import Response @@ -5,10 +7,20 @@ from slack_bolt.oauth import OAuthFlow -def to_bolt_request(req: Request, body: bytes) -> BoltRequest: - return BoltRequest( - body=body.decode("utf-8"), query=req.query_params, headers=req.headers, +def to_bolt_request( + req: Request, + body: bytes, + addition_context_properties: Optional[Dict[str, Any]] = None, +) -> BoltRequest: + request = BoltRequest( + body=body.decode("utf-8"), + query=req.query_params, # type: ignore[arg-type] + headers=req.headers, # type: ignore[arg-type] ) + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + return request def to_starlette_response(bolt_resp: BoltResponse) -> Response: @@ -22,10 +34,10 @@ def to_starlette_response(bolt_resp: BoltResponse) -> Response: resp.set_cookie( key=name, value=c.value, - max_age=c.get("max-age", None), - expires=c.get("expires", None), - path=c.get("path", None), - domain=c.get("domain", None), + max_age=c.get("max-age"), + expires=c.get("expires"), + path=c.get("path"), + domain=c.get("domain"), secure=True, httponly=True, ) @@ -33,24 +45,25 @@ def to_starlette_response(bolt_resp: BoltResponse) -> Response: class SlackRequestHandler: - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app - async def handle(self, req: Request) -> Response: + async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response: body = await req.body() if req.method == "GET": if self.app.oauth_flow is not None: oauth_flow: OAuthFlow = self.app.oauth_flow if req.url.path == oauth_flow.install_path: - bolt_resp = oauth_flow.handle_installation( - to_bolt_request(req, body) - ) + bolt_resp = oauth_flow.handle_installation(to_bolt_request(req, body, addition_context_properties)) return to_starlette_response(bolt_resp) elif req.url.path == oauth_flow.redirect_uri_path: - bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body)) + bolt_resp = oauth_flow.handle_callback(to_bolt_request(req, body, addition_context_properties)) return to_starlette_response(bolt_resp) elif req.method == "POST": - bolt_resp = self.app.dispatch(to_bolt_request(req, body)) + bolt_resp = self.app.dispatch(to_bolt_request(req, body, addition_context_properties)) return to_starlette_response(bolt_resp) - return Response(status_code=404, content="Not found",) + return Response( + status_code=404, + content="Not found", + ) diff --git a/slack_bolt/adapter/tornado/__init__.py b/slack_bolt/adapter/tornado/__init__.py index dfda87bf3..44499f261 100644 --- a/slack_bolt/adapter/tornado/__init__.py +++ b/slack_bolt/adapter/tornado/__init__.py @@ -1 +1,7 @@ +# Don't add async module imports here from .handler import SlackEventsHandler, SlackOAuthHandler + +__all__ = [ + "SlackEventsHandler", + "SlackOAuthHandler", +] diff --git a/slack_bolt/adapter/tornado/async_handler.py b/slack_bolt/adapter/tornado/async_handler.py new file mode 100644 index 000000000..1dfc3d374 --- /dev/null +++ b/slack_bolt/adapter/tornado/async_handler.py @@ -0,0 +1,44 @@ +from tornado.httputil import HTTPServerRequest +from tornado.web import RequestHandler + +from slack_bolt.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from .handler import set_response + + +class AsyncSlackEventsHandler(RequestHandler): + def initialize(self, app: AsyncApp): + self.app = app + + async def post(self): + bolt_resp: BoltResponse = await self.app.async_dispatch(to_async_bolt_request(self.request)) + set_response(self, bolt_resp) + return + + +class AsyncSlackOAuthHandler(RequestHandler): + def initialize(self, app: AsyncApp): + self.app = app + + async def get(self): + if self.app.oauth_flow is not None: + oauth_flow: AsyncOAuthFlow = self.app.oauth_flow + if self.request.path == oauth_flow.install_path: + bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(self.request)) + set_response(self, bolt_resp) + return + elif self.request.path == oauth_flow.redirect_uri_path: + bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(self.request)) + set_response(self, bolt_resp) + return + self.set_status(404) + + +def to_async_bolt_request(req: HTTPServerRequest) -> AsyncBoltRequest: + return AsyncBoltRequest( + body=req.body.decode("utf-8") if req.body else "", + query=req.query, + headers=req.headers, # type: ignore[arg-type] + ) diff --git a/slack_bolt/adapter/tornado/handler.py b/slack_bolt/adapter/tornado/handler.py index 272cb1a11..088c9665e 100644 --- a/slack_bolt/adapter/tornado/handler.py +++ b/slack_bolt/adapter/tornado/handler.py @@ -10,7 +10,7 @@ class SlackEventsHandler(RequestHandler): - def initialize(self, app: App): # type: ignore + def initialize(self, app: App): self.app = app def post(self): @@ -20,16 +20,14 @@ def post(self): class SlackOAuthHandler(RequestHandler): - def initialize(self, app: App): # type: ignore + def initialize(self, app: App): self.app = app def get(self): - if self.app.oauth_flow is not None: # type: ignore - oauth_flow: OAuthFlow = self.app.oauth_flow # type: ignore + if self.app.oauth_flow is not None: + oauth_flow: OAuthFlow = self.app.oauth_flow if self.request.path == oauth_flow.install_path: - bolt_resp = oauth_flow.handle_installation( - to_bolt_request(self.request) - ) + bolt_resp = oauth_flow.handle_installation(to_bolt_request(self.request)) set_response(self, bolt_resp) return elif self.request.path == oauth_flow.redirect_uri_path: @@ -43,7 +41,7 @@ def to_bolt_request(req: HTTPServerRequest) -> BoltRequest: return BoltRequest( body=req.body.decode("utf-8") if req.body else "", query=req.query, - headers=req.headers, + headers=req.headers, # type: ignore[arg-type] ) @@ -54,19 +52,15 @@ def set_response(self, bolt_resp) -> None: self.set_header(name, value) for cookie in bolt_resp.cookies(): for name, c in cookie.items(): - expire_value = c.get("expires", None) - expire = ( - datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") - if expire_value - else None - ) + expire_value = c.get("expires") + expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None self.set_cookie( name=name, value=c.value, - max_age=c.get("max-age", None), + max_age=c.get("max-age"), expires=expire, - path=c.get("path", None), - domain=c.get("domain", None), + path=c.get("path"), + domain=c.get("domain"), secure=True, httponly=True, ) diff --git a/slack_bolt/adapter/wsgi/__init__.py b/slack_bolt/adapter/wsgi/__init__.py new file mode 100644 index 000000000..bf7cf78a4 --- /dev/null +++ b/slack_bolt/adapter/wsgi/__init__.py @@ -0,0 +1,3 @@ +from .handler import SlackRequestHandler + +__all__ = ["SlackRequestHandler"] diff --git a/slack_bolt/adapter/wsgi/handler.py b/slack_bolt/adapter/wsgi/handler.py new file mode 100644 index 000000000..fef54f73e --- /dev/null +++ b/slack_bolt/adapter/wsgi/handler.py @@ -0,0 +1,89 @@ +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 +from slack_bolt.response import BoltResponse + + +class SlackRequestHandler: + def __init__(self, app: App, path: str = "/slack/events"): + """Setup Bolt as a WSGI web framework, this will make your application compatible with WSGI web servers. + This can be used for production deployments. + + With the default settings, `http://localhost:3000/slack/events` + Run Bolt with [gunicorn](https://gunicorn.org/) + + # Python + app = App() + + api = SlackRequestHandler(app) + + # bash + export SLACK_SIGNING_SECRET=*** + + export SLACK_BOT_TOKEN=xoxb-*** + + gunicorn app:api -b 0.0.0.0:3000 --log-level debug + + Args: + app: Your bolt application + path: The path to handle request from Slack (Default: `/slack/events`) + """ + self.path = path + self.app = app + + def dispatch(self, request: WsgiHttpRequest) -> BoltResponse: + return self.app.dispatch( + BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers()) + ) + + def handle_installation(self, request: WsgiHttpRequest) -> BoltResponse: + return self.app.oauth_flow.handle_installation( # type: ignore[union-attr] + BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers()) + ) + + def handle_callback(self, request: WsgiHttpRequest) -> BoltResponse: + return self.app.oauth_flow.handle_callback( # type: ignore[union-attr] + BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers()) + ) + + def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse: + if request.method == "GET": + if self.app.oauth_flow is not None: + if request.path == self.app.oauth_flow.install_path: + bolt_response = self.handle_installation(request) + return WsgiHttpResponse( + status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body + ) + elif request.path == self.app.oauth_flow.redirect_uri_path: + bolt_response = self.handle_callback(request) + return WsgiHttpResponse( + status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body + ) + if request.method == "POST" and request.path == self.path: + bolt_response = self.dispatch(request) + return WsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body) + return WsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found") + + def __call__( + self, + environ: "WSGIEnvironment", + start_response: "StartResponse", + ) -> Iterable[bytes]: + request = WsgiHttpRequest(environ) + if request.protocol.startswith("HTTP"): + response: WsgiHttpResponse = self._get_http_response( + request=request, + ) + 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 new file mode 100644 index 000000000..644d0e333 --- /dev/null +++ b/slack_bolt/adapter/wsgi/http_request.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING, Dict, Sequence, Union + +if TYPE_CHECKING: + from wsgiref.types import WSGIEnvironment + +from .internals import ENCODING + + +class WsgiHttpRequest: + """This Class uses the PEP 3333 standard to extract request information + from the WSGI web server running the application + + PEP 3333: https://peps.python.org/pep-3333/ + """ + + __slots__ = ("method", "path", "query_string", "protocol", "environ") + + def __init__(self, environ: "WSGIEnvironment"): + self.method: str = environ.get("REQUEST_METHOD", "GET") + self.path: str = environ.get("PATH_INFO", "") + self.query_string: str = environ.get("QUERY_STRING", "") + self.protocol: str = environ.get("SERVER_PROTOCOL", "") + self.environ = environ + + def get_headers(self) -> Dict[str, Union[str, Sequence[str]]]: + headers = {} + for key, value in self.environ.items(): + if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}: + name = key.lower().replace("_", "-") + headers[name] = value + if key.startswith("HTTP_"): + name = key[len("HTTP_"):].lower().replace("_", "-") # fmt: skip + headers[name] = value + return headers + + def get_body(self) -> str: + if "wsgi.input" not in self.environ: + return "" + content_length = int(self.environ.get("CONTENT_LENGTH") 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 new file mode 100644 index 000000000..32956d276 --- /dev/null +++ b/slack_bolt/adapter/wsgi/http_response.py @@ -0,0 +1,34 @@ +from http import HTTPStatus +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from .internals import ENCODING + + +class WsgiHttpResponse: + """This Class uses the PEP 3333 standard to adapt bolt response information + for the WSGI web server running the application + + PEP 3333: https://peps.python.org/pep-3333/ + """ + + __slots__ = ("status", "_headers", "_body") + + def __init__(self, status: int, headers: Optional[Dict[str, Sequence[str]]] = None, body: str = ""): + _status = HTTPStatus(status) + self.status = f"{_status.value} {_status.phrase}" + self._headers = headers or {} + self._body = bytes(body, ENCODING) + + def get_headers(self) -> List[Tuple[str, str]]: + headers: List[Tuple[str, str]] = [] + for key, values in self._headers.items(): + if key.lower() == "content-length": + continue + for v in values: + headers.append((key, v)) + + headers.append(("content-length", str(len(self._body)))) + return headers + + def get_body(self) -> Iterable[bytes]: + return [self._body] diff --git a/slack_bolt/adapter/wsgi/internals.py b/slack_bolt/adapter/wsgi/internals.py new file mode 100644 index 000000000..cda3e876a --- /dev/null +++ b/slack_bolt/adapter/wsgi/internals.py @@ -0,0 +1 @@ +ENCODING = "utf-8" # The content encoding for Slack requests/responses is always utf-8 diff --git a/slack_bolt/app/__init__.py b/slack_bolt/app/__init__.py index 6363c1bca..455f2b949 100644 --- a/slack_bolt/app/__init__.py +++ b/slack_bolt/app/__init__.py @@ -1,2 +1,14 @@ +# flake8: noqa +"""Application interface in Bolt. + +For most use cases, we recommend using `slack_bolt.app.app`. +If you already have knowledge about asyncio and prefer the programming model, +you can use `slack_bolt.app.async_app` for building async apps.\ +""" + # Don't add async module imports here -from .app import App # type: ignore +from .app import App + +__all__ = [ + "App", +] diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 0e1a397b0..e20649902 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -3,22 +3,63 @@ import logging import os import time +import warnings +from concurrent.futures import Executor from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer -from typing import List, Union, Pattern, Callable, Dict, Optional +from typing import List, Union, Pattern, Callable, Dict, Optional, Sequence, Any -from slack_sdk.oauth import OAuthStateUtils -from slack_sdk.oauth.installation_store import InstallationStore, FileInstallationStore -from slack_sdk.oauth.state_store import OAuthStateStore, FileOAuthStateStore +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth.installation_store import InstallationStore from slack_sdk.web import WebClient -from slack_bolt.error import BoltError +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.authorization.authorize import ( + Authorize, + InstallationStoreAuthorize, + CallableAuthorize, +) + +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + +from slack_bolt.error import BoltError, BoltUnhandledRequestError +from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner +from slack_bolt.listener.builtins import TokenRevocationListeners from slack_bolt.listener.custom_listener import CustomListener from slack_bolt.listener.listener import Listener +from slack_bolt.listener.listener_start_handler import DefaultListenerStartHandler +from slack_bolt.listener.listener_completion_handler import ( + DefaultListenerCompletionHandler, +) +from slack_bolt.listener.listener_error_handler import ( + DefaultListenerErrorHandler, + CustomListenerErrorHandler, +) +from slack_bolt.listener.thread_runner import ThreadListenerRunner from slack_bolt.listener_matcher import CustomListenerMatcher from slack_bolt.listener_matcher import builtins as builtin_matchers from slack_bolt.listener_matcher.listener_matcher import ListenerMatcher from slack_bolt.logger import get_bolt_app_logger, get_bolt_logger +from slack_bolt.logger.messages import ( + error_oauth_flow_or_authorize_required, + warning_client_prioritized_and_token_skipped, + warning_token_skipped, + error_auth_test_failure, + error_token_required, + warning_unhandled_request, + debug_checking_listener, + debug_applying_middleware, + debug_running_listener, + error_unexpected_listener_middleware, + error_client_invalid_type, + error_authorize_conflicts, + warning_bot_only_conflicts, + debug_return_listener_middleware_response, + info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, + warning_unhandled_by_global_middleware, + warning_ack_timeout_has_no_effect, +) from slack_bolt.middleware import ( Middleware, SslCheck, @@ -27,185 +68,399 @@ MultiTeamsAuthorization, IgnoringSelfEvents, CustomMiddleware, + AttachingFunctionToken, + AttachingConversationKwargs, +) +from slack_bolt.middleware.assistant import Assistant +from slack_bolt.middleware.message_listener_matches import MessageListenerMatches +from slack_bolt.middleware.middleware_error_handler import ( + DefaultMiddlewareErrorHandler, + CustomMiddlewareErrorHandler, + MiddlewareErrorHandler, ) from slack_bolt.middleware.url_verification import UrlVerification from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.internals import select_consistent_installation_store +from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_web_client +from slack_bolt.util.utils import ( + create_web_client, + get_boot_message, + get_name_for_callable, +) +from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware +from slack_bolt.workflows.step.step import WorkflowStepBuilder class App: def __init__( self, *, + logger: Optional[logging.Logger] = None, # Used in logger name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps token: Optional[str] = None, + token_verification_enabled: bool = True, client: Optional[WebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[Middleware, Callable[..., Any]]] = None, + authorize: Optional[Callable[..., AuthorizeResult]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[InstallationStore] = None, - oauth_state_store: Optional[OAuthStateStore] = None, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # for either only bot scope usage or v1.0.x compatibility + installation_store_bot_only: Optional[bool] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ignoring_self_assistant_message_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + attaching_function_token_enabled: bool = True, # for the OAuth flow + oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, - authorization_test_enabled: bool = True, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - oauth_install_path: Optional[str] = None, - oauth_redirect_uri_path: Optional[str] = None, - oauth_success_url: Optional[str] = None, - oauth_failure_url: Optional[str] = None, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, + # Set this one only when you want to customize the executor + listener_executor: Optional[Executor] = None, + # for AI Agents & Assistants + assistant_thread_context_store: Optional[AssistantThreadContextStore] = None, + attaching_conversation_kwargs_enabled: bool = True, ): - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", None) - token = token or os.environ.get("SLACK_BOT_TOKEN", None) + """Bolt App that provides functionalities to register middleware/listeners. + + import os + from slack_bolt import App - if signing_secret is None or signing_secret == "": - raise BoltError( - "Signing secret not found, so could not initialize the Bolt app." + # Initializes your app with your bot token and signing secret + app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) + # Listens to incoming messages that contain "hello" + @app.message("hello") + def message_hello(message, say): + # say() sends a message to the channel where the event was triggered + say(f"Hey there <@{message['user']}>!") + + # Start your app + if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) + + Refer to https://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: + logger: The custom logger that can be used in this app. + name: The application name that will be used in logging. If absent, the source file name will be used. + process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) + signing_secret: The Signing Secret value used for verifying requests from Slack. + token: The bot/user access token required only for single-workspace app. + token_verification_enabled: Verifies the validity of the given token if True. + client: The singleton `slack_sdk.WebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function + authorize: The function to authorize an incoming request from Slack + by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store + installation_store: The module offering save/find operations of installation data + installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `RequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware. + `IgnoringSelfEvents` for this app's bot user message events within an assistant thread + This is useful for avoiding code error causing an infinite loop; Default: True + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `UrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True). + `AttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution tokens + when your app receives `function_executed` or interactivity events scoped to a custom step. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. + oauth_settings: The settings related to Slack app installation flow (OAuth flow) + oauth_flow: Instantiated `slack_bolt.oauth.OAuthFlow`. This is always prioritized over oauth_settings. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. + listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will + be used. + assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation, + which uses a parent message's metadata to store the latest context) + """ + if signing_secret is None: + signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "") + token = token or os.environ.get("SLACK_BOT_TOKEN") + self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret - client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) - client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET", None) - scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") - user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") - redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) - oauth_install_path = oauth_install_path or os.environ.get( - "SLACK_INSTALL_PATH", "/slack/install" - ) - oauth_redirect_uri_path = oauth_redirect_uri_path or os.environ.get( - "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" - ) - - self._verification_token: Optional[str] = verification_token or os.environ.get( - "SLACK_VERIFICATION_TOKEN", None - ) - self._framework_logger = get_bolt_logger(App) + self._verification_token: Optional[str] = verification_token or os.environ.get("SLACK_VERIFICATION_TOKEN", None) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. + self._framework_logger = logger or get_bolt_logger(App) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token if client is not None: + if not isinstance(client, WebClient): + raise BoltError(error_client_invalid_type()) self._client = client self._token = client.token if token is not None: - self._framework_logger.warning( - "As you gave client as well, the bot token will be unused." - ) + self._framework_logger.warning(warning_client_prioritized_and_token_skipped()) else: - self._client = create_web_client(token) # NOTE: the token here can be None + self._client = create_web_client( + # NOTE: the token here can be None + token=token, + logger=self._framework_logger, + ) - self._installation_store: Optional[InstallationStore] = installation_store - self._oauth_state_store: Optional[OAuthStateStore] = oauth_state_store + # -------------------------------------- + # Authorize & OAuthFlow initialization + # -------------------------------------- + + self._before_authorize: Optional[Middleware] = None + if before_authorize is not None: + if callable(before_authorize): + self._before_authorize = CustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, Middleware): + self._before_authorize = before_authorize + + self._authorize: Optional[Authorize] = None + if authorize is not None: + if isinstance(authorize, Authorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) - self._oauth_state_cookie_name = oauth_state_cookie_name - self._oauth_state_expiration_seconds = oauth_state_expiration_seconds + self._installation_store: Optional[InstallationStore] = installation_store + if self._installation_store is not None and self._authorize is None: + settings = oauth_flow.settings if oauth_flow is not None else oauth_settings + self._authorize = InstallationStoreAuthorize( + installation_store=self._installation_store, + client_id=settings.client_id if settings is not None else None, + client_secret=settings.client_secret if settings is not None else None, + logger=self._framework_logger, + bot_only=installation_store_bot_only or False, + client=self._client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), + ) self._oauth_flow: Optional[OAuthFlow] = None - self._authorization_test_enabled = authorization_test_enabled - if oauth_flow: - self._oauth_flow = oauth_flow - if self._installation_store is None: - self._installation_store = self._oauth_flow.installation_store - if self._oauth_state_store is None: - self._oauth_state_store = self._oauth_flow.oauth_state_store - if self._oauth_flow._client is None: - self._oauth_flow._client = self._client - else: - if client_id is not None and client_secret is not None: - # The OAuth flow support is enabled - if self._installation_store is None and self._oauth_state_store is None: - # use the default ones - self._installation_store = FileInstallationStore( - client_id=client_id, - ) - self._oauth_state_store = FileOAuthStateStore( - expiration_seconds=self._oauth_state_expiration_seconds, - client_id=client_id, - ) - if ( - self._installation_store is not None - and self._oauth_state_store is None - ): - raise ValueError( - f"Configure an appropriate OAuthStateStore for {self._installation_store}" - ) + if ( + oauth_settings is None + and os.environ.get("SLACK_CLIENT_ID") is not None + and os.environ.get("SLACK_CLIENT_SECRET") is not None + ): + # initialize with the default settings + oauth_settings = OAuthSettings() - self._oauth_flow = OAuthFlow( - client=create_web_client(), - logger=self._framework_logger, - # required storage implementations - installation_store=self._installation_store, - oauth_state_store=self._oauth_state_store, - oauth_state_cookie_name=self._oauth_state_cookie_name, - oauth_state_expiration_seconds=self._oauth_state_expiration_seconds, - # used for oauth.v2.access calls - client_id=client_id, - client_secret=client_secret, - # installation url parameters - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, - # path in this app - install_path=oauth_install_path, - redirect_uri_path=oauth_redirect_uri_path, - # urls after callback - success_url=oauth_success_url, - failure_url=oauth_failure_url, - ) + if oauth_flow is None and installation_store is None: + # show info-level log for avoiding confusions + self._framework_logger.info(info_default_oauth_settings_loaded()) - if self._installation_store is not None and self._token is not None: - self._token = None - self._framework_logger.warning( - "As you gave installation_store as well, the bot token will be unused." + if oauth_flow is not None: + self._oauth_flow = oauth_flow + installation_store = select_consistent_installation_store( + client_id=self._oauth_flow.client_id, + app_store=self._installation_store, + oauth_flow_store=self._oauth_flow.settings.installation_store, + logger=self._framework_logger, ) + self._installation_store = installation_store + if installation_store is not None: + self._oauth_flow.settings.installation_store = installation_store - self._middleware_list: List[Union[Callable, Middleware]] = [] + if self._oauth_flow._client is None: + self._oauth_flow._client = self._client + if self._authorize is None: + self._authorize = self._oauth_flow.settings.authorize + elif oauth_settings is not None: + installation_store = select_consistent_installation_store( + client_id=oauth_settings.client_id, + app_store=self._installation_store, + oauth_flow_store=oauth_settings.installation_store, + logger=self._framework_logger, + ) + self._installation_store = installation_store + if installation_store is not None: + oauth_settings.installation_store = installation_store + self._oauth_flow = OAuthFlow(client=self.client, logger=self.logger, settings=oauth_settings) + if self._authorize is None: + self._authorize = self._oauth_flow.settings.authorize + self._authorize.token_rotation_expiration_minutes = oauth_settings.token_rotation_expiration_minutes # type: ignore[attr-defined] # noqa: E501 + + if (self._installation_store is not None or self._authorize is not None) and self._token is not None: + self._token = None + self._framework_logger.warning(warning_token_skipped()) + + # after setting bot_only here, __init__ cannot replace authorize function + if installation_store_bot_only is not None and self._oauth_flow is not None: + app_bot_only = installation_store_bot_only or False + oauth_flow_bot_only = self._oauth_flow.settings.installation_store_bot_only + if app_bot_only != oauth_flow_bot_only: + self.logger.warning(warning_bot_only_conflicts()) + self._oauth_flow.settings.installation_store_bot_only = app_bot_only + self._authorize.bot_only = app_bot_only # type: ignore[union-attr] + + self._tokens_revocation_listeners: Optional[TokenRevocationListeners] = None + if self._installation_store is not None: + self._tokens_revocation_listeners = TokenRevocationListeners(self._installation_store) + + # -------------------------------------- + # Middleware Initialization + # -------------------------------------- + + self._middleware_list: List[Middleware] = [] self._listeners: List[Listener] = [] - self._listener_executor = ThreadPoolExecutor(max_workers=5) # TODO: shutdown + + if listener_executor is None: + 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( + logger=self._framework_logger, + process_before_response=process_before_response, + listener_error_handler=DefaultListenerErrorHandler(logger=self._framework_logger), + listener_start_handler=DefaultListenerStartHandler(logger=self._framework_logger), + listener_completion_handler=DefaultListenerCompletionHandler(logger=self._framework_logger), + listener_executor=listener_executor, + lazy_listener_runner=ThreadLazyListenerRunner( + logger=self._framework_logger, + executor=listener_executor, + ), + ) + self._middleware_error_handler: MiddlewareErrorHandler = DefaultMiddlewareErrorHandler( + logger=self._framework_logger, + ) self._init_middleware_list_done = False - self._init_middleware_list() + self._init_middleware_list( + token_verification_enabled=token_verification_enabled, + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, + attaching_function_token_enabled=attaching_function_token_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) - def _init_middleware_list(self): + def _init_middleware_list( + self, + token_verification_enabled: bool = True, + 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, + user_facing_authorize_error_message: Optional[str] = None, + ): if self._init_middleware_list_done: return - self._middleware_list.append( - SslCheck(verification_token=self._verification_token) - ) - self._middleware_list.append(RequestVerification(self._signing_secret)) + if ssl_check_enabled is True: + self._middleware_list.append( + SslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) + ) + if request_verification_enabled is True: + self._middleware_list.append(RequestVerification(self._signing_secret, base_logger=self._base_logger)) + if self._before_authorize is not None: + self._middleware_list.append(self._before_authorize) + + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._oauth_flow is None: - if self._token: - self._middleware_list.append(SingleTeamAuthorization()) + if self._token is not None: + try: + auth_test_result = None + if token_verification_enabled: + # This API call is for eagerly validating the token + auth_test_result = self._client.auth_test(token=self._token) + self._middleware_list.append( + SingleTeamAuthorization( + auth_test_result=auth_test_result, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) + except SlackApiError as err: + raise BoltError(error_auth_test_failure(err.response)) + elif self._authorize is not None: + self._middleware_list.append( + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) else: - raise BoltError( - "OAuthFlow not found, so could not initialize the Bolt app." + raise BoltError(error_token_required()) + elif self._authorize is not None: + self._middleware_list.append( + MultiTeamsAuthorization( + authorize=self._authorize, + base_logger=self._base_logger, + user_token_resolution=self._oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) + ) else: + raise BoltError(error_oauth_flow_or_authorize_required()) + + if ignoring_self_events_enabled is True: self._middleware_list.append( - MultiTeamsAuthorization( - installation_store=self._installation_store, - verification_enabled=self._authorization_test_enabled, + IgnoringSelfEvents( + base_logger=self._base_logger, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, ) ) - self._middleware_list.append(IgnoringSelfEvents()) - self._middleware_list.append(UrlVerification()) + if url_verification_enabled is True: + self._middleware_list.append(UrlVerification(base_logger=self._base_logger)) + if attaching_function_token_enabled is True: + self._middleware_list.append(AttachingFunctionToken()) self._init_middleware_list_done = True # ------------------------- @@ -213,198 +468,494 @@ def _init_middleware_list(self): @property def name(self) -> str: + """The name of this app (default: the filename)""" return self._name @property def oauth_flow(self) -> Optional[OAuthFlow]: + """Configured `OAuthFlow` object if exists.""" return self._oauth_flow @property def logger(self) -> logging.Logger: + """The logger this app uses.""" return self._framework_logger @property def client(self) -> WebClient: + """The singleton `slack_sdk.WebClient` instance in this app.""" return self._client @property def installation_store(self) -> Optional[InstallationStore]: + """The `slack_sdk.oauth.InstallationStore` that can be used in the `authorize` middleware.""" return self._installation_store @property - def oauth_state_store(self) -> Optional[OAuthStateStore]: - return self._oauth_state_store + def listener_runner(self) -> ThreadListenerRunner: + """The thread executor for asynchronously running listeners.""" + return self._listener_runner + + @property + def process_before_response(self) -> bool: + return self._process_before_response or False # ------------------------- # standalone server - def start(self, port: int = 3000, path: str = "/slack/events") -> None: - self.server = SlackAppServer( - port=port, path=path, app=self, oauth_flow=self.oauth_flow, + def start( + self, + port: int = 3000, + path: str = "/slack/events", + http_server_logger_enabled: bool = True, + ) -> None: + """Starts a web server for local development. + + # With the default settings, `http://localhost:3000/slack/events` + # is available for handling incoming requests from Slack + app.start() + + This method internally starts a Web server process built with the `http.server` module. + For production, consider using a production-ready WSGI server such as Gunicorn. + + Args: + port: The port to listen on (Default: 3000) + path: The path to handle request from Slack (Default: `/slack/events`) + http_server_logger_enabled: The flag to enable http.server logging if True (Default: True) + """ + self._development_server = SlackAppDevelopmentServer( + port=port, + path=path, + app=self, + oauth_flow=self.oauth_flow, + http_server_logger_enabled=http_server_logger_enabled, ) - self.server.start() + self._development_server.start() # ------------------------- # main dispatcher def dispatch(self, req: BoltRequest) -> BoltResponse: + """Applies all middleware and dispatches an incoming request from Slack to the right code path. + + Args: + req: An incoming request from Slack + + Returns: + The response generated by this Bolt app + """ + starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} def middleware_next(): middleware_state["next_called"] = True - for middleware in self._middleware_list: - middleware_state["next_called"] = False - if self._framework_logger.level <= logging.DEBUG: - self._framework_logger.debug(f"Applying {middleware.name}") - resp = middleware.process(req=req, resp=resp, next=middleware_next) - if not middleware_state["next_called"]: + try: + for middleware in self._middleware_list: + middleware_state["next_called"] = False + if self._framework_logger.level <= logging.DEBUG: + self._framework_logger.debug(debug_applying_middleware(middleware.name)) + resp = middleware.process(req=req, resp=resp, next=middleware_next) # type: ignore[arg-type] + if not middleware_state["next_called"]: + if resp is None: + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) + if self._raise_error_for_unhandled_request is True: + try: + raise BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) + return resp + self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) + return resp + return resp + + for listener in self._listeners: + listener_name = get_name_for_callable(listener.ack_function) + self._framework_logger.debug(debug_checking_listener(listener_name)) + if listener.matches(req=req, resp=resp): # type: ignore[arg-type] + # run all the middleware attached to this listener first + middleware_resp, next_was_not_called = listener.run_middleware( + req=req, resp=resp # type: ignore[arg-type] + ) + if next_was_not_called: + if middleware_resp is not None: + if self._framework_logger.level <= logging.DEBUG: + debug_message = debug_return_listener_middleware_response( + listener_name, + middleware_resp.status, + middleware_resp.body, + starting_time, + ) + self._framework_logger.debug(debug_message) + return middleware_resp + # The last listener middleware didn't call next() method. + # This means the listener is not for this incoming request. + continue + + if middleware_resp is not None: + resp = middleware_resp + + self._framework_logger.debug(debug_running_listener(listener_name)) + listener_response: Optional[BoltResponse] = self._listener_runner.run( + request=req, + response=resp, # type: ignore[arg-type] + listener_name=listener_name, + listener=listener, + ) + if listener_response is not None: + return listener_response + + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + try: + raise BoltUnhandledRequestError( + request=req, + current_response=resp, + ) + except BoltUnhandledRequestError as e: + self._listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) return resp + return self._handle_unmatched_requests(req, resp) + except Exception as error: + resp = BoltResponse(status=500, body="") + self._middleware_error_handler.handle( + error=error, + request=req, + response=resp, + ) + return resp - for listener in self._listeners: - listener_name = listener.func.__name__ - self._framework_logger.debug(f"Checking listener: {listener_name} ...") - if listener.matches(req=req, resp=resp): - # run all the middleware attached to this listener first - resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) - if next_was_not_called: - # The last listener middleware didn't call next() method. - # This means the listener is not for this incoming request. - continue - - self._framework_logger.debug(f"Running listener: {listener_name} ...") - listener_response: Optional[BoltResponse] = self.run_listener( - request=req, - response=resp, - listener_name=listener_name, - listener=listener, - ) - if listener_response is not None: - return listener_response + def _handle_unmatched_requests(self, req: BoltRequest, resp: BoltResponse) -> BoltResponse: + self._framework_logger.warning(warning_unhandled_request(req)) + return resp - self._framework_logger.warning(f"Unhandled request ({req.payload})") - return BoltResponse(status=404, body={"error": "unhandled request"}) + # ------------------------- + # middleware - def run_listener( - self, - request: BoltRequest, - response: BoltResponse, - listener_name: str, - listener: Listener, - ) -> Optional[BoltResponse]: - ack = request.context.ack - starting_time = time.time() - if self._process_before_response: - returned_value = listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and listener.auto_acknowledgement: - ack() # automatic ack() call if the call is not yet done - - if response is not None: - self._debug_log_completion(starting_time, response) - return response - elif ack.response is not None: - self._debug_log_completion(starting_time, ack.response) - return ack.response - else: - # start the listener function asynchronously - def run_ack_function_asynchronously(): - try: - listener.run_ack_function(request=request, response=response) - except Exception as e: - # TODO: error handler - self._framework_logger.exception( - f"Failed to run listener function (error: {e})" - ) + def use(self, *args) -> Optional[Callable]: + """Registers a new global middleware to this app. This method can be used as either a decorator or a method. - self._listener_executor.submit(run_ack_function_asynchronously) + Refer to `App#middleware()` method's docstring for details.""" + return self.middleware(*args) - if listener.auto_acknowledgement: - # acknowledge immediately in case of Events API - ack() - else: - # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: - time.sleep(0.01) - - if response is None and ack.response is not None: - response = ack.response - self._debug_log_completion(starting_time, response) - return response - else: - self._framework_logger.warning(f"{listener_name} didn't call ack()") + def middleware(self, *args) -> Optional[Callable]: + """Registers a new middleware to this app. + This method can be used as either a decorator or a method. - # None for both means no ack() in the listener + # Use this method as a decorator + @app.middleware + def middleware_func(logger, body, next): + logger.info(f"request body: {body}") + next() + + # Pass a function to this method + app.middleware(middleware_func) + + Refer to https://docs.slack.dev/tools/bolt-python/concepts/global-middleware for details. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + *args: A function that works as a global middleware. + """ + if len(args) > 0: + middleware_or_callable = args[0] + if isinstance(middleware_or_callable, Middleware): + middleware: Middleware = middleware_or_callable + self._middleware_list.append(middleware) + if isinstance(middleware, Assistant) and middleware.thread_context_store is not None: + self._assistant_thread_context_store = middleware.thread_context_store + elif callable(middleware_or_callable): + self._middleware_list.append( + CustomMiddleware( + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, + ) + ) + return middleware_or_callable + else: + raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})") return None - def _debug_log_completion( - self, starting_time: float, response: BoltResponse - ) -> None: - millis = int((time.time() - starting_time) * 1000) - self._framework_logger.debug( - f'Responding with status: {response.status} body: "{response.body}" ({millis} millis)' + # ------------------------- + # AI Agents & Assistants + + def assistant(self, assistant: Assistant) -> Optional[Callable]: + return self.middleware(assistant) + + # ------------------------- + # Workflows: Steps from apps + + def step( + self, + callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder], + edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, + save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, + execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new step from app listener. + + Unlike others, this method doesn't behave as a decorator. + If you want to register a step from app by a decorator, use `WorkflowStepBuilder`'s methods. + + # Create a new WorkflowStep instance + from slack_bolt.workflows.step import WorkflowStep + ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + # Pass Step to set up listeners + app.step(ws) + + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + For further information about WorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to `slack_bolt.workflows.step.utilities` API documents. + + Args: + callback_id: The Callback ID for this step from app + edit: The function for displaying a modal in the Workflow Builder + save: The function for handling configuration in the Workflow Builder + execute: The function for handling the step execution + """ + warnings.warn( + ( + "Steps from apps for legacy workflows are now deprecated. " + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" + ), + category=DeprecationWarning, ) + step = callback_id + if isinstance(callback_id, (str, Pattern)): + step = WorkflowStep( + callback_id=callback_id, + edit=edit, # type: ignore[arg-type] + save=save, # type: ignore[arg-type] + execute=execute, # type: ignore[arg-type] + base_logger=self._base_logger, + ) + elif isinstance(step, WorkflowStepBuilder): + step = step.build(base_logger=self._base_logger) + elif not isinstance(step, WorkflowStep): + raise BoltError(f"Invalid step object ({type(step)})") + + self.use(WorkflowStepMiddleware(step)) # ------------------------- - # middleware + # global error handler - def use(self, *args): - return self.middleware(*args) + def error(self, func: Callable[..., Optional[BoltResponse]]) -> Callable[..., Optional[BoltResponse]]: + """Updates the global error handler. This method can be used as either a decorator or a method. - def middleware(self, *args): - if len(args) > 0: - func = args[0] - self._middleware_list.append( - CustomMiddleware(app_name=self.name, func=func) - ) + # Use this method as a decorator + @app.error + def custom_error_handler(error, body, logger): + logger.exception(f"Error: {error}") + logger.info(f"Request body: {body}") + + # Pass a function to this method + app.error(custom_error_handler) + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + func: The function that is supposed to be executed + when getting an unhandled error in Bolt app. + """ + self._listener_runner.listener_error_handler = CustomListenerErrorHandler( + logger=self._framework_logger, + func=func, + ) + self._middleware_error_handler = CustomMiddlewareErrorHandler( + logger=self._framework_logger, + func=func, + ) + return func # ------------------------- # events def event( self, - event: Union[str, Pattern, Dict[str, str]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.event(event) - return self._register_listener( - func, primary_matcher, matchers, middleware, True - ) + event: Union[ + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], + ], + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new event listener. This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.event("team_join") + def ask_for_introduction(event, say): + welcome_channel_id = "C12345" + user_id = event["user"] + text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel." + say(text=text, channel=welcome_channel_id) + + # Pass a function to this method + app.event("team_join")(ask_for_introduction) + + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + event: The conditions that match a request payload. + If you pass a dict for this, you can have type, subtype in the constraint. + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + 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__ def message( self, - keyword: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - matchers = matchers if matchers else [] - - def __call__(func): - primary_matcher = builtin_matchers.event("message") - - def keyword_matcher(payload) -> bool: - text: Optional[str] = payload.get("event", {}).get("text", {}) - if text: - if isinstance(keyword, Pattern): - return keyword.match(text) # type: ignore - elif isinstance(keyword, str): - return keyword in text - return False - - matchers.insert(0, keyword_matcher) - return self._register_listener( - func, primary_matcher, matchers, middleware, True + keyword: Union[str, Pattern] = "", + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new message event listener. This method can be used as either a decorator or a method. + Check the `App#event` method's docstring for details. + + # Use this method as a decorator + @app.message(":wave:") + def say_hello(message, say): + user = message['user'] + say(f"Hi there, <@{user}>!") + + # Pass a function to this method + app.message(":wave:")(say_hello) + + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + keyword: The keyword to match + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", + ), + } + primary_matcher = builtin_matchers.message_event( + keyword=keyword, constraints=constraints, base_logger=self._base_logger ) + 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) + + return __call__ + + def function( + self, + callback_id: Union[str, Pattern], + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + auto_acknowledge: bool = True, + ack_timeout: int = 3, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new Function listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.function("reverse") + def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail): + try: + ack() + string_to_reverse = inputs["stringToReverse"] + complete(outputs={"reverseString": string_to_reverse[::-1]}) + except Exception as e: + fail(f"Cannot reverse string (error: {e})") + raise e + + # Pass a function to this method + app.function("reverse")(reverse_string) + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + callback_id: The callback id to identify the function + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -414,12 +965,38 @@ def keyword_matcher(payload) -> bool: def command( self, command: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.command(command) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new slash command listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.command("/echo") + def repeat_text(ack, say, command): + # Acknowledge command request + ack() + say(f"{command['text']}") + + # Pass a function to this method + app.command("/echo")(repeat_text) + + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + command: The conditions that match a request payload + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -429,36 +1006,74 @@ def __call__(func): def shortcut( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.shortcut(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new shortcut listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.shortcut("open_modal") + def open_modal(ack, body, client): + # Acknowledge the command request + ack() + # Call views_open with the built-in client + client.views_open( + # Pass a valid trigger_id within 3 seconds of receiving it + trigger_id=body["trigger_id"], + # View payload + view={ ... } + ) + + # Pass a function to this method + app.shortcut("open_modal")(open_modal) + + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + constraints: The conditions that match a request payload. + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.shortcut(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def global_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.global_shortcut(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new global shortcut listener.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.global_shortcut(callback_id, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def message_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.message_shortcut(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new message shortcut listener.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.message_shortcut(callback_id, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -468,60 +1083,102 @@ def __call__(func): def action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.action(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new action listener. This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.action("approve_button") + def update_message(ack): + ack() + + # Pass a function to this method + app.action("approve_button")(update_message) + + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + constraints: The conditions that match a request payload + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.action(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def block_action( self, - action_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.block_action(action_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `block_actions` action listener. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.block_action(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def attachment_action( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.attachment_action(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `interactive_message` action listener. + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.attachment_action(callback_id, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def dialog_submission( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.dialog_submission(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `dialog_submission` listener. + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.dialog_submission(callback_id, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def dialog_cancellation( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.dialog_cancellation(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `dialog_cancellation` listener. + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.dialog_cancellation(callback_id, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -531,36 +1188,82 @@ def __call__(func): def view( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.view(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `view_submission`/`view_closed` event listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.view("view_1") + def handle_submission(ack, body, client, view): + # Assume there's an input block with `block_c` as the block_id and `dreamy_input` + hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"] + user = body["user"]["id"] + # Validate the inputs + errors = {} + if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5: + errors["block_c"] = "The value must be longer than 5 characters" + if len(errors) > 0: + ack(response_action="errors", errors=errors) + return + # Acknowledge the view_submission event and close the modal + ack() + # Do whatever you want with the input data - here we're saving it to a DB + + # Pass a function to this method + app.view("view_1")(handle_submission) + + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + constraints: The conditions that match a request payload + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.view(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def view_submission( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.view_submission(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `view_submission` listener. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.view_submission(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def view_closed( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.view_closed(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `view_closed` listener. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -570,111 +1273,254 @@ def __call__(func): def options( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.options(constraints) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new options listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.options("menu_selection") + def show_menu_options(ack): + options = [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ] + ack(options=options) + + # Pass a function to this method + app.options("menu_selection")(show_menu_options) + + Refer to the following documents for details: + + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + + Args: + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.options(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def block_suggestion( self, action_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.block_suggestion(action_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `block_suggestion` listener.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.block_suggestion(action_id, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def dialog_suggestion( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.dialog_suggestion(callback_id) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new `dialog_suggestion` listener. + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.dialog_suggestion(callback_id, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Optional[BoltResponse]]: + if self._tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: BoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger(app_name=self.name, base_logger=self._base_logger) req.context["token"] = self._token - req.context["client"] = self._client + # Prior to version 1.15, when the token is static, self._client was passed to `req.context`. + # The intention was to avoid creating a new instance per request + # in the interest of runtime performance/memory footprint optimization. + # However, developers may want to replace the token held by req.context.client in some situations. + # In this case, this behavior can result in thread-unsafe data modification on `self._client`. + # (`self._client` a.k.a. `app.client` is a singleton object per an App instance) + # Thus, we've changed the behavior to create a new instance per request regardless of token argument + # in the App initialization starting v1.15. + # The overhead brought by this change is slight so that we believe that it is ignorable in any cases. + client_per_request: WebClient = WebClient( + token=self._token, # this can be None, and it can be set later on + base_url=self._client.base_url, + timeout=self._client.timeout, + ssl=self._client.ssl, + proxy=self._client.proxy, + headers=self._client.headers, + team_id=req.context.team_id, + logger=self._client.logger, + retry_handlers=self._client.retry_handlers.copy() if self._client.retry_handlers is not None else None, + ) + req.context["client"] = client_per_request + + # Most apps do not need this "listener_runner" instance. + # It is intended for apps that start lazy listeners from their custom global middleware. + req.context["listener_runner"] = self.listener_runner + + @staticmethod + def _to_listener_functions( + kwargs: dict, + ) -> Optional[Sequence[Callable[..., Optional[BoltResponse]]]]: + if kwargs: + functions = [kwargs["ack"]] + for sub in kwargs["lazy"]: + functions.append(sub) + return functions + return None def _register_listener( self, - func: Callable[..., BoltResponse], + functions: Sequence[Callable[..., Optional[BoltResponse]]], primary_matcher: ListenerMatcher, - matchers: Optional[List[Callable[..., bool]]], - middleware: Optional[List[Union[Callable, Middleware]]], + matchers: Optional[Sequence[Callable[..., bool]]], + middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, - ) -> None: - - listener_matchers = [ - CustomListenerMatcher(app_name=self.name, func=f) for f in (matchers or []) + ack_timeout: int = 3, + ) -> Optional[Callable[..., Optional[BoltResponse]]]: + value_to_return = None + if not isinstance(functions, list): + functions = list(functions) + if len(functions) == 1: + # In the case where the function is registered using decorator, + # the registration should return the original function. + value_to_return = functions[0] + + listener_matchers: List[ListenerMatcher] = [ + CustomListenerMatcher(app_name=self.name, func=f, base_logger=self._base_logger) for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) listener_middleware = [] for m in middleware or []: if isinstance(m, Middleware): listener_middleware.append(m) - elif isinstance(m, Callable): - listener_middleware.append(CustomMiddleware(app_name=self.name, func=m)) + elif callable(m): + listener_middleware.append(CustomMiddleware(app_name=self.name, func=m, base_logger=self._base_logger)) else: - raise ValueError( - f"Unexpected value for a listener middleware: {type(m)}" - ) + raise ValueError(error_unexpected_listener_middleware(type(m))) self._listeners.append( CustomListener( app_name=self.name, - func=func, + ack_function=functions.pop(0), + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, + base_logger=self._base_logger, ) ) + return value_to_return # ------------------------- -class SlackAppServer: +class SlackAppDevelopmentServer: def __init__( - self, port: int, path: str, app: App, oauth_flow: Optional[OAuthFlow] = None, + self, + port: int, + path: str, + app: App, + oauth_flow: Optional[OAuthFlow] = None, + http_server_logger_enabled: bool = True, ): + """Slack App Development Server + + This is a thin wrapper of http.server.HTTPServer and is good enough + for your local development or prototyping. + + However, as mentioned in Python official documents, using http.server module in production + is not recommended. Please consider using an adapter (refer to slack_bolt.adapter.*) + along with a production-grade server when running the app for end users. + https://docs.python.org/3/library/http.server.html#http.server.HTTPServer + + Args: + port: the port number + path: the path to receive incoming requests + app: the `App` instance to execute + oauth_flow: the `OAuthFlow` instance to use for OAuth flow + http_server_logger_enabled: The flag to turn on/off http.server's logging + """ self._port: int = port self._bolt_endpoint_path: str = path self._bolt_app: App = app - self._bolt_oauth_flow: OAuthFlow = oauth_flow + self._bolt_oauth_flow: Optional[OAuthFlow] = oauth_flow + self._http_server_logger_enabled = http_server_logger_enabled _port: int = self._port _bolt_endpoint_path: str = self._bolt_endpoint_path _bolt_app: App = self._bolt_app - _bolt_oauth_flow: OAuthFlow = self._bolt_oauth_flow + _bolt_oauth_flow: Optional[OAuthFlow] = self._bolt_oauth_flow + _http_server_logger_enabled = self._http_server_logger_enabled class SlackAppHandler(SimpleHTTPRequestHandler): + def log_message(self, format: str, *args: Any) -> None: + if _http_server_logger_enabled is True: + super().log_message(format, *args) + def do_GET(self): if _bolt_oauth_flow: request_path, _, query = self.path.partition("?") if request_path == _bolt_oauth_flow.install_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, ) bolt_resp = _bolt_oauth_flow.handle_installation(bolt_req) self._send_bolt_response(bolt_resp) elif request_path == _bolt_oauth_flow.redirect_uri_path: bolt_req = BoltRequest( - body="", query=query, headers=self.headers + body="", + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, ) bolt_resp = _bolt_oauth_flow.handle_callback(bolt_req) self._send_bolt_response(bolt_resp) @@ -692,7 +1538,10 @@ def do_POST(self): len_header = self.headers.get("Content-Length") or 0 request_body = self.rfile.read(int(len_header)).decode("utf-8") bolt_req = BoltRequest( - body=request_body, query=query, headers=self.headers + body=request_body, + query=query, + # email.message.Message's mapping interface is dict compatible + headers=self.headers, ) bolt_resp: BoltResponse = _bolt_app.dispatch(bolt_req) self._send_bolt_response(bolt_resp) @@ -707,7 +1556,7 @@ def _send_bolt_response(self, bolt_resp: BoltResponse): def _send_response( self, status: int, - headers: Dict[str, List[str]], + headers: Dict[str, Sequence[str]], body: Union[str, dict] = "", ): self.send_response(status) @@ -718,17 +1567,18 @@ def _send_response( for k, vs in headers.items(): for v in vs: self.send_header(k, v) - self.send_header("Content-Length", len(body_bytes)) + self.send_header("Content-Length", str(len(body_bytes))) self.end_headers() self.wfile.write(body_bytes) self._server = HTTPServer(("0.0.0.0", self._port), SlackAppHandler) - def start(self): + def start(self) -> None: + """Starts a new web server process.""" if self._bolt_app.logger.level > logging.INFO: - print("⚡️ Bolt app is running!") + print(get_boot_message(development_server=True)) else: - self._bolt_app.logger.info("⚡️ Bolt app is running!") + self._bolt_app.logger.info(get_boot_message(development_server=True)) try: self._server.serve_forever(0.05) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 91d6eb702..cc94f9e15 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -1,22 +1,79 @@ -import asyncio import inspect import logging import os import time -from asyncio import Future -from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable +from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence, Any +import warnings -from slack_sdk.oauth import OAuthStateUtils -from slack_sdk.oauth.installation_store import FileInstallationStore +from aiohttp import web + +from slack_bolt.app.async_server import AsyncSlackAppServer +from slack_bolt.context.assistant.thread_context_store.async_store import ( + AsyncAssistantThreadContextStore, +) +from slack_bolt.listener.async_builtins import AsyncTokenRevocationListeners +from slack_bolt.listener.async_listener_start_handler import ( + AsyncDefaultListenerStartHandler, +) +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncDefaultListenerCompletionHandler, +) +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.middleware.assistant.async_assistant import AsyncAssistant +from slack_bolt.middleware.async_middleware_error_handler import ( + AsyncCustomMiddlewareErrorHandler, + AsyncDefaultMiddlewareErrorHandler, + AsyncMiddlewareErrorHandler, +) +from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( + AsyncMessageListenerMatches, +) +from slack_bolt.oauth.async_internals import select_consistent_installation_store +from slack_bolt.util.utils import get_name_for_callable, is_callable_coroutine +from slack_bolt.workflows.step.async_step import ( + AsyncWorkflowStep, + AsyncWorkflowStepBuilder, +) +from slack_bolt.workflows.step.async_step_middleware import AsyncWorkflowStepMiddleware from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) -from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore from slack_sdk.web.async_client import AsyncWebClient -from slack_bolt.error import BoltError +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.authorization.async_authorize import ( + AsyncAuthorize, + AsyncCallableAuthorize, + AsyncInstallationStoreAuthorize, +) +from slack_bolt.error import BoltError, BoltUnhandledRequestError +from slack_bolt.logger.messages import ( + error_oauth_flow_or_authorize_required, + warning_client_prioritized_and_token_skipped, + warning_token_skipped, + error_token_required, + warning_unhandled_request, + debug_checking_listener, + debug_running_listener, + error_unexpected_listener_middleware, + error_listener_function_must_be_coro_func, + error_client_invalid_type_async, + error_authorize_conflicts, + error_oauth_settings_invalid_type_async, + error_oauth_flow_invalid_type_async, + warning_bot_only_conflicts, + debug_return_listener_middleware_response, + info_default_oauth_settings_loaded, + error_installation_store_required_for_builtin_listeners, + warning_unhandled_by_global_middleware, + warning_ack_timeout_has_no_effect, +) +from slack_bolt.lazy_listener.asyncio_runner import AsyncioLazyListenerRunner from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener +from slack_bolt.listener.async_listener_error_handler import ( + AsyncDefaultListenerErrorHandler, + AsyncCustomListenerErrorHandler, +) from slack_bolt.listener_matcher import builtins as builtin_matchers from slack_bolt.listener_matcher.async_listener_matcher import ( AsyncListenerMatcher, @@ -28,6 +85,8 @@ AsyncRequestVerification, AsyncIgnoringSelfEvents, AsyncUrlVerification, + AsyncAttachingFunctionToken, + AsyncAttachingConversationKwargs, ) from slack_bolt.middleware.async_custom_middleware import ( AsyncMiddleware, @@ -40,6 +99,7 @@ AsyncSingleTeamAuthorization, ) from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.async_utils import create_async_web_client @@ -49,179 +109,357 @@ class AsyncApp: def __init__( self, *, + logger: Optional[logging.Logger] = None, # Used in logger name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps + before_authorize: Optional[Union[AsyncMiddleware, Callable[..., Awaitable[Any]]]] = None, + authorize: Optional[Callable[..., Awaitable[AuthorizeResult]]] = None, + user_facing_authorize_error_message: Optional[str] = None, installation_store: Optional[AsyncInstallationStore] = None, - oauth_state_store: Optional[AsyncOAuthStateStore] = None, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # for either only bot scope usage or v1.0.x compatibility + installation_store_bot_only: Optional[bool] = None, + # for customizing the built-in middleware + request_verification_enabled: bool = True, + ignoring_self_events_enabled: bool = True, + ignoring_self_assistant_message_events_enabled: bool = True, + ssl_check_enabled: bool = True, + url_verification_enabled: bool = True, + attaching_function_token_enabled: bool = True, # for the OAuth flow + oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - oauth_install_path: Optional[str] = None, - oauth_redirect_uri_path: Optional[str] = None, - oauth_success_url: Optional[str] = None, - oauth_failure_url: Optional[str] = None, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, + # for AI Agents & Assistants + assistant_thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + attaching_conversation_kwargs_enabled: bool = True, ): - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET", None) - token = token or os.environ.get("SLACK_BOT_TOKEN", None) + """Bolt App that provides functionalities to register middleware/listeners. - if signing_secret is None or signing_secret == "": - raise BoltError( - "Signing secret not found, so could not initialize the Bolt app." + import os + from slack_bolt.async_app import AsyncApp + + # Initializes your app with your bot token and signing secret + app = AsyncApp( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET") ) + # Listens to incoming messages that contain "hello" + @app.message("hello") + async def message_hello(message, say): # async function + # say() sends a message to the channel where the event was triggered + await say(f"Hey there <@{message['user']}>!") + + # Start your app + if __name__ == "__main__": + app.start(port=int(os.environ.get("PORT", 3000))) + + Refer to https://docs.slack.dev/tools/bolt-python/concepts/async for details. + + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, + refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth to learn how to configure the app. + + Args: + logger: The custom logger that can be used in this app. + name: The application name that will be used in logging. If absent, the source file name will be used. + process_before_response: True if this app runs on Function as a Service. (Default: False) + raise_error_for_unhandled_request: True if you want to raise exceptions for unhandled requests + and use @app.error listeners instead of + the built-in handler, which pints warning logs and returns 404 to Slack (Default: False) + signing_secret: The Signing Secret value used for verifying requests from Slack. + token: The bot/user access token required only for single-workspace app. + client: The singleton `slack_sdk.web.async_client.AsyncWebClient` instance for this app. + before_authorize: A global middleware that can be executed right before authorize function + authorize: The function to authorize an incoming request from Slack + by checking if there is a team/user in the installation data. + user_facing_authorize_error_message: The user-facing error message to display + when the app is installed but the installation is not managed by this app's installation store + installation_store: The module offering save/find operations of installation data + installation_store_bot_only: Use `AsyncInstallationStore#async_find_bot()` if True (Default: False) + request_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncRequestVerification` is a built-in middleware that verifies the signature in HTTP Mode requests. + Make sure if it's safe enough when you turn a built-in middleware off. + We strongly recommend using RequestVerification for better security. + If you have a proxy that verifies request signature in front of the Bolt app, + it's totally fine to disable RequestVerification to avoid duplication of work. + Don't turn it off just for easiness of development. + ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events + generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware. + `IgnoringSelfEvents` for this app's bot user message events within an assistant thread + This is useful for avoiding code error causing an infinite loop; Default: True + url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncUrlVerification` is a built-in middleware that handles url_verification requests + that verify the endpoint for Events API in HTTP Mode requests. + ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). + `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack. + attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncAttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution token + when your app receives `function_executed` or interactivity events scoped to a custom step. + oauth_settings: The settings related to Slack app installation flow (OAuth flow) + oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. + assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation, + which uses a parent message's metadata to store the latest context) + """ + if signing_secret is None: + signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "") + token = token or os.environ.get("SLACK_BOT_TOKEN") + self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret - - client_id = client_id or os.environ.get("SLACK_CLIENT_ID", None) - client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET", None) - scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") - user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") - redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI", None) - oauth_install_path = oauth_install_path or os.environ.get( - "SLACK_INSTALL_PATH", "/slack/install" - ) - oauth_redirect_uri_path = oauth_redirect_uri_path or os.environ.get( - "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" - ) - - self._verification_token: Optional[str] = verification_token or os.environ.get( - "SLACK_VERIFICATION_TOKEN", None - ) - self._framework_logger = get_bolt_logger(AsyncApp) + self._verification_token: Optional[str] = verification_token or os.environ.get("SLACK_VERIFICATION_TOKEN", None) + # If a logger is explicitly passed when initializing, the logger works as the base logger. + # The base logger's logging settings will be propagated to all the loggers created by bolt-python. + self._base_logger = logger + # The framework logger is supposed to be used for the internal logging. + # Also, it's accessible via `app.logger` as the app's singleton logger. + self._framework_logger = logger or get_bolt_logger(AsyncApp) + self._raise_error_for_unhandled_request = raise_error_for_unhandled_request self._token: Optional[str] = token if client is not None: + if not isinstance(client, AsyncWebClient): + raise BoltError(error_client_invalid_type_async()) self._async_client = client self._token = client.token if token is not None: - self._framework_logger.warning( - "As you gave client as well, the bot token will be unused." - ) + self._framework_logger.warning(warning_client_prioritized_and_token_skipped()) else: - # NOTE: the token here can be None - self._async_client = create_async_web_client(token) - - self._async_installation_store: Optional[ - AsyncInstallationStore - ] = installation_store - self._async_oauth_state_store: Optional[ - AsyncOAuthStateStore - ] = oauth_state_store + self._async_client = create_async_web_client( + # NOTE: the token here can be None + token=token, + logger=self._framework_logger, + ) - self._oauth_state_cookie_name = oauth_state_cookie_name - self._oauth_state_expiration_seconds = oauth_state_expiration_seconds + # -------------------------------------- + # Authorize & OAuthFlow initialization + # -------------------------------------- + + self._async_before_authorize: Optional[AsyncMiddleware] = None + if before_authorize is not None: + if callable(before_authorize): + self._async_before_authorize = AsyncCustomMiddleware( + app_name=self._name, + func=before_authorize, + base_logger=self._framework_logger, + ) + elif isinstance(before_authorize, AsyncMiddleware): + self._async_before_authorize = before_authorize + + self._async_authorize: Optional[AsyncAuthorize] = None + if authorize is not None: + if isinstance(authorize, AsyncAuthorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._async_authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) + + self._async_installation_store: Optional[AsyncInstallationStore] = installation_store + if self._async_installation_store is not None and self._async_authorize is None: + settings = oauth_flow.settings if oauth_flow is not None else oauth_settings + self._async_authorize = AsyncInstallationStoreAuthorize( + installation_store=self._async_installation_store, + client_id=settings.client_id if settings is not None else None, + client_secret=settings.client_secret if settings is not None else None, + logger=self._framework_logger, + bot_only=installation_store_bot_only or False, + client=self._async_client, # for proxy use cases etc. + user_token_resolution=(settings.user_token_resolution if settings is not None else "authed_user"), + ) self._async_oauth_flow: Optional[AsyncOAuthFlow] = None + + if ( + oauth_settings is None + and os.environ.get("SLACK_CLIENT_ID") is not None + and os.environ.get("SLACK_CLIENT_SECRET") is not None + ): + # initialize with the default settings + oauth_settings = AsyncOAuthSettings() + + if oauth_flow is None and installation_store is None: + # show info-level log for avoiding confusions + self._framework_logger.info(info_default_oauth_settings_loaded()) + if oauth_flow: + if not isinstance(oauth_flow, AsyncOAuthFlow): + raise BoltError(error_oauth_flow_invalid_type_async()) + self._async_oauth_flow = oauth_flow - if self._async_installation_store is None: - self._async_installation_store = ( - self._async_oauth_flow.installation_store - ) - if self._async_oauth_state_store is None: - self._async_oauth_state_store = self._async_oauth_flow.oauth_state_store + installation_store = select_consistent_installation_store( + client_id=self._async_oauth_flow.client_id, + app_store=self._async_installation_store, + oauth_flow_store=self._async_oauth_flow.settings.installation_store, + logger=self._framework_logger, + ) + self._async_installation_store = installation_store + if installation_store is not None: + self._async_oauth_flow.settings.installation_store = installation_store + if self._async_oauth_flow._async_client is None: self._async_oauth_flow._async_client = self._async_client - else: - if client_id is not None and client_secret is not None: - # The OAuth flow support is enabled - if ( - self._async_installation_store is None - and self._async_oauth_state_store is None - ): - # use the default ones - self._async_installation_store = FileInstallationStore( - client_id=client_id, - ) - self._async_oauth_state_store = FileOAuthStateStore( - expiration_seconds=self._oauth_state_expiration_seconds, - client_id=client_id, - ) - - if ( - self._async_installation_store is not None - and self._async_oauth_state_store is None - ): - raise ValueError( - f"Configure an appropriate OAuthStateStore for {self._async_installation_store}" - ) + if self._async_authorize is None: + self._async_authorize = self._async_oauth_flow.settings.authorize + elif oauth_settings is not None: + if not isinstance(oauth_settings, AsyncOAuthSettings): + raise BoltError(error_oauth_settings_invalid_type_async()) + + installation_store = select_consistent_installation_store( + client_id=oauth_settings.client_id, + app_store=self._async_installation_store, + oauth_flow_store=oauth_settings.installation_store, + logger=self._framework_logger, + ) + self._async_installation_store = installation_store + if installation_store is not None: + oauth_settings.installation_store = installation_store - self._async_oauth_flow = AsyncOAuthFlow( - client=create_async_web_client(), - logger=self._framework_logger, - # required storage implementations - installation_store=self._async_installation_store, - oauth_state_store=self._async_oauth_state_store, - oauth_state_cookie_name=self._oauth_state_cookie_name, - oauth_state_expiration_seconds=self._oauth_state_expiration_seconds, - # used for oauth.v2.access calls - client_id=client_id, - client_secret=client_secret, - # installation url parameters - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, - # path in this app - install_path=oauth_install_path, - redirect_uri_path=oauth_redirect_uri_path, - # urls after callback - success_url=oauth_success_url, - failure_url=oauth_failure_url, - ) + self._async_oauth_flow = AsyncOAuthFlow(client=self._async_client, logger=self.logger, settings=oauth_settings) + if self._async_authorize is None: + self._async_authorize = self._async_oauth_flow.settings.authorize + self._async_authorize.token_rotation_expiration_minutes = oauth_settings.token_rotation_expiration_minutes # type: ignore[attr-defined] # noqa: E501 - if self._async_installation_store is not None and self._token is not None: + if (self._async_installation_store is not None or self._async_authorize is not None) and self._token is not None: self._token = None - self._framework_logger.warning( - "As you gave installation_store as well, the bot token will be unused." - ) - - self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] + self._framework_logger.warning(warning_token_skipped()) + + # after setting bot_only here, __init__ cannot replace authorize function + if installation_store_bot_only is not None and self._async_oauth_flow is not None: + app_bot_only = installation_store_bot_only or False + oauth_flow_bot_only = self._async_oauth_flow.settings.installation_store_bot_only + if app_bot_only != oauth_flow_bot_only: + self.logger.warning(warning_bot_only_conflicts()) + self._async_oauth_flow.settings.installation_store_bot_only = app_bot_only + self._async_authorize.bot_only = app_bot_only # type: ignore[union-attr] + + self._async_tokens_revocation_listeners: Optional[AsyncTokenRevocationListeners] = None + if self._async_installation_store is not None: + self._async_tokens_revocation_listeners = AsyncTokenRevocationListeners(self._async_installation_store) + + # -------------------------------------- + # Middleware Initialization + # -------------------------------------- + + self._async_middleware_list: List[AsyncMiddleware] = [] self._async_listeners: List[AsyncListener] = [] + + self._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( + logger=self._framework_logger, + process_before_response=process_before_response, + listener_error_handler=AsyncDefaultListenerErrorHandler(logger=self._framework_logger), + listener_start_handler=AsyncDefaultListenerStartHandler(logger=self._framework_logger), + listener_completion_handler=AsyncDefaultListenerCompletionHandler(logger=self._framework_logger), + lazy_listener_runner=AsyncioLazyListenerRunner( + logger=self._framework_logger, + ), + ) + self._async_middleware_error_handler: AsyncMiddlewareErrorHandler = AsyncDefaultMiddlewareErrorHandler( + logger=self._framework_logger, + ) self._init_middleware_list_done = False - self._init_async_middleware_list() + self._init_async_middleware_list( + request_verification_enabled=request_verification_enabled, + ignoring_self_events_enabled=ignoring_self_events_enabled, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, + ssl_check_enabled=ssl_check_enabled, + url_verification_enabled=url_verification_enabled, + attaching_function_token_enabled=attaching_function_token_enabled, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) - def _init_async_middleware_list(self): + self._server: Optional[AsyncSlackAppServer] = None + + def _init_async_middleware_list( + self, + 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, + user_facing_authorize_error_message: Optional[str] = None, + ): if self._init_middleware_list_done: return - self._async_middleware_list.append( - AsyncSslCheck(verification_token=self._verification_token) - ) - self._async_middleware_list.append( - AsyncRequestVerification(self._signing_secret) - ) + if ssl_check_enabled is True: + self._async_middleware_list.append( + AsyncSslCheck( + verification_token=self._verification_token, + base_logger=self._base_logger, + ) + ) + if request_verification_enabled is True: + self._async_middleware_list.append(AsyncRequestVerification(self._signing_secret, base_logger=self._base_logger)) + + if self._async_before_authorize is not None: + self._async_middleware_list.append(self._async_before_authorize) + + # As authorize is required for making a Bolt app function, we don't offer the flag to disable this if self._async_oauth_flow is None: if self._token: - self._async_middleware_list.append(AsyncSingleTeamAuthorization()) + self._async_middleware_list.append( + AsyncSingleTeamAuthorization( + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) + elif self._async_authorize is not None: + self._async_middleware_list.append( + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_facing_authorize_error_message=user_facing_authorize_error_message, + ) + ) else: - raise BoltError( - "AsyncOAuthFlow not found, so could not initialize the Bolt app." + raise BoltError(error_token_required()) + elif self._async_authorize is not None: + self._async_middleware_list.append( + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + user_token_resolution=self._async_oauth_flow.settings.user_token_resolution, + user_facing_authorize_error_message=user_facing_authorize_error_message, ) + ) else: + raise BoltError(error_oauth_flow_or_authorize_required()) + + if ignoring_self_events_enabled is True: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(self._async_installation_store) + AsyncIgnoringSelfEvents( + base_logger=self._base_logger, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, + ) ) - - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) - self._async_middleware_list.append(AsyncUrlVerification()) + if url_verification_enabled is True: + self._async_middleware_list.append(AsyncUrlVerification(base_logger=self._base_logger)) + if attaching_function_token_enabled is True: + self._async_middleware_list.append(AsyncAttachingFunctionToken()) self._init_middleware_list_done = True # ------------------------- @@ -229,213 +467,527 @@ def _init_async_middleware_list(self): @property def name(self) -> str: + """The name of this app (default: the filename)""" return self._name @property def oauth_flow(self) -> Optional[AsyncOAuthFlow]: + """Configured `OAuthFlow` object if exists.""" return self._async_oauth_flow @property def client(self) -> AsyncWebClient: + """The singleton `slack_sdk.web.async_client.AsyncWebClient` instance in this app.""" return self._async_client @property def logger(self) -> logging.Logger: + """The logger this app uses.""" return self._framework_logger @property def installation_store(self) -> Optional[AsyncInstallationStore]: + """The `slack_sdk.oauth.AsyncInstallationStore` that can be used in the `authorize` middleware.""" return self._async_installation_store @property - def oauth_state_store(self) -> Optional[AsyncOAuthStateStore]: - return self._async_oauth_state_store + def listener_runner(self) -> AsyncioListenerRunner: + """The asyncio-based executor for asynchronously running listeners.""" + return self._async_listener_runner + + @property + def process_before_response(self) -> bool: + return self._process_before_response or False # ------------------------- # standalone server - def start(self, port: int = 3000, path: str = "/slack/events") -> None: - from .async_server import AsyncSlackAppServer + from .async_server import AsyncSlackAppServer + + def server( + self, + port: int = 3000, + path: str = "/slack/events", + host: Optional[str] = None, + ) -> AsyncSlackAppServer: + """Configure a web server using AIOHTTP. + Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. + + Args: + port: The port to listen on (Default: 3000) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) + """ + if self._server is None or self._server.port != port or self._server.path != path: + self._server = AsyncSlackAppServer( + port=port, + path=path, + app=self, + host=host, + ) + return self._server + + def web_app(self, path: str = "/slack/events", port: int = 3000) -> web.Application: + """Returns a `web.Application` instance for aiohttp-devtools users. + + from slack_bolt.async_app import AsyncApp + app = AsyncApp() + + @app.event("app_mention") + async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + + def app_factory(): + return app.web_app() + + # adev runserver --port 3000 --app-factory app_factory async_app.py + + Args: + path: The path to receive incoming requests from Slack + port: The port to listen on (Default: 3000) + """ + return self.server(path=path, port=port).web_app + + def start(self, port: int = 3000, path: str = "/slack/events", host: Optional[str] = None) -> None: + """Start a web server using AIOHTTP. + Refer to https://docs.aiohttp.org/ for more details about AIOHTTP. - self.server = AsyncSlackAppServer(port=port, path=path, app=self,) - self.server.start() + Args: + port: The port to listen on (Default: 3000) + path: The path to handle request from Slack (Default: `/slack/events`) + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) + """ + self.server(port=port, path=path, host=host).start() # ------------------------- # main dispatcher async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse: + """Applies all middleware and dispatches an incoming request from Slack to the right code path. + + Args: + req: An incoming request from Slack. + + Returns: + The response generated by this Bolt app. + """ + starting_time = time.time() self._init_context(req) - resp: BoltResponse = BoltResponse(status=200, body="") + resp: Optional[BoltResponse] = BoltResponse(status=200, body="") middleware_state = {"next_called": False} async def async_middleware_next(): middleware_state["next_called"] = True - for middleware in self._async_middleware_list: - middleware_state["next_called"] = False - if self._framework_logger.level <= logging.DEBUG: - self._framework_logger.debug(f"Applying {middleware.name}") - resp = await middleware.async_process( - req=req, resp=resp, next=async_middleware_next - ) - if not middleware_state["next_called"]: - return resp - - for listener in self._async_listeners: - listener_name = listener.func.__name__ - self._framework_logger.debug(f"Checking listener: {listener_name} ...") - if await listener.async_matches(req=req, resp=resp): - # run all the middleware attached to this listener first - resp, next_was_not_called = await listener.run_async_middleware( - req=req, resp=resp + try: + for middleware in self._async_middleware_list: + middleware_state["next_called"] = False + if self._framework_logger.level <= logging.DEBUG: + self._framework_logger.debug(f"Applying {middleware.name}") + resp = await middleware.async_process( + req=req, resp=resp, next=async_middleware_next # type: ignore[arg-type] ) - if next_was_not_called: - # The last listener middleware didn't call next() method. - # This means the listener is not for this incoming request. - continue - - self._framework_logger.debug(f"Running listener: {listener_name} ...") - listener_response: Optional[ - BoltResponse - ] = await self.run_async_listener( - request=req, - response=resp, - listener_name=listener_name, - async_listener=listener, - ) - if listener_response is not None: - return listener_response - - self._framework_logger.warning(f"Unhandled request ({req.payload})") - return BoltResponse(status=404, body={"error": "unhandled request"}) - - async def run_async_listener( - self, - request: AsyncBoltRequest, - response: BoltResponse, - listener_name: str, - async_listener: AsyncListener, - ) -> Optional[BoltResponse]: - ack = request.context.ack - starting_time = time.time() - if self._process_before_response: - returned_value = await async_listener.run_ack_function( - request=request, response=response - ) - if isinstance(returned_value, BoltResponse): - response = returned_value - if ack.response is None and async_listener.auto_acknowledgement: - await ack() # automatic ack() call if the call is not yet done - - if response is not None: - self._debug_log_completion(starting_time, response) - return response - elif ack.response is not None: - self._debug_log_completion(starting_time, ack.response) - return ack.response - else: - if async_listener.auto_acknowledgement: - # acknowledge immediately in case of Events API - await ack() + if not middleware_state["next_called"]: + if resp is None: + # next() method was not called without providing the response to return to Slack + # This should not be an intentional handling in usual use cases. + resp = BoltResponse(status=404, body={"error": "no next() calls in middleware"}) + if self._raise_error_for_unhandled_request is True: + try: + raise BoltUnhandledRequestError( + request=req, + current_response=resp, + last_global_middleware_name=middleware.name, + ) + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, + ) + return resp + self._framework_logger.warning(warning_unhandled_by_global_middleware(middleware.name, req)) + return resp + return resp + + for listener in self._async_listeners: + listener_name = get_name_for_callable(listener.ack_function) + self._framework_logger.debug(debug_checking_listener(listener_name)) + if await listener.async_matches(req=req, resp=resp): # type: ignore[arg-type] + # run all the middleware attached to this listener first + middleware_resp, next_was_not_called = await listener.run_async_middleware( + req=req, resp=resp # type: ignore[arg-type] + ) + if next_was_not_called: + if middleware_resp is not None: + if self._framework_logger.level <= logging.DEBUG: + debug_message = debug_return_listener_middleware_response( + listener_name, + middleware_resp.status, + middleware_resp.body, + starting_time, + ) + self._framework_logger.debug(debug_message) + return middleware_resp + # The last listener middleware didn't call next() method. + # This means the listener is not for this incoming request. + continue + + if middleware_resp is not None: + resp = middleware_resp + + self._framework_logger.debug(debug_running_listener(listener_name)) + listener_response: Optional[BoltResponse] = await self._async_listener_runner.run( + request=req, + response=resp, # type: ignore[arg-type] + listener_name=listener_name, + listener=listener, + ) + if listener_response is not None: + return listener_response - # start the listener function asynchronously - # NOTE: intentionally - async def run_ack_function_asynchronously( - request: AsyncBoltRequest, response: BoltResponse, - ): + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: try: - await async_listener.run_ack_function( - request=request, response=response + raise BoltUnhandledRequestError( + request=req, + current_response=resp, ) - except Exception as e: - # TODO: error handler - self._framework_logger.exception( - f"Failed to run listener function (error: {e})" + except BoltUnhandledRequestError as e: + await self._async_listener_runner.listener_error_handler.handle( + error=e, + request=req, + response=resp, ) - - _f: Future = asyncio.ensure_future( - run_ack_function_asynchronously(request, response) + return resp + return self._handle_unmatched_requests(req, resp) + + except Exception as error: + resp = BoltResponse(status=500, body="") + await self._async_middleware_error_handler.handle( + error=error, + request=req, + response=resp, ) - self._framework_logger.debug(f"Async listener: {listener_name} started..") - - # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: - await asyncio.sleep(0.01) - - if ack.response is not None: - response = ack.response - self._debug_log_completion(starting_time, response) - return response - else: - self._framework_logger.warning(f"{listener_name} didn't call ack()") - - # None for both means no ack() in the listener - return None + return resp - def _debug_log_completion( - self, starting_time: float, response: BoltResponse - ) -> None: - millis = int((time.time() - starting_time) * 1000) - self._framework_logger.debug( - f'Responding with status: {response.status} body: "{response.body}" ({millis} millis)' - ) + def _handle_unmatched_requests(self, req: AsyncBoltRequest, resp: BoltResponse) -> BoltResponse: + self._framework_logger.warning(warning_unhandled_request(req)) + return resp # ------------------------- # middleware - def use(self, *args): + def use(self, *args) -> Optional[Callable]: + """Refer to `AsyncApp#middleware()` method's docstring for details.""" return self.middleware(*args) - def middleware(self, *args): + def middleware(self, *args) -> Optional[Callable]: + """Registers a new middleware to this app. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.middleware + async def middleware_func(logger, body, next): + logger.info(f"request body: {body}") + await next() + + # Pass a function to this method + app.middleware(middleware_func) + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + *args: A function that works as a global middleware. + """ if len(args) > 0: - func = args[0] - self._async_middleware_list.append( - AsyncCustomMiddleware(app_name=self.name, func=func) + middleware_or_callable = args[0] + if isinstance(middleware_or_callable, AsyncMiddleware): + middleware: AsyncMiddleware = middleware_or_callable + self._async_middleware_list.append(middleware) + if isinstance(middleware, AsyncAssistant) and middleware.thread_context_store is not None: + self._assistant_thread_context_store = middleware.thread_context_store + elif callable(middleware_or_callable): + self._async_middleware_list.append( + AsyncCustomMiddleware( + app_name=self.name, + func=middleware_or_callable, + base_logger=self._base_logger, + ) + ) + return middleware_or_callable + else: + raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})") + return None + + def assistant(self, assistant: AsyncAssistant) -> Optional[Callable]: + return self.middleware(assistant) + + # ------------------------- + # Workflows: Steps from apps + + def step( + self, + callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder], + edit: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, + save: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, + execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new step from app listener. + + Unlike others, this method doesn't behave as a decorator. + If you want to register a step from app by a decorator, use `AsyncWorkflowStepBuilder`'s methods. + + # Create a new WorkflowStep instance + from slack_bolt.workflows.async_step import AsyncWorkflowStep + ws = AsyncWorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, ) + # Pass Step to set up listeners + app.step(ws) + + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details of steps from apps. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + For further information about AsyncWorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents. + + Args: + callback_id: The Callback ID for this step from app + edit: The function for displaying a modal in the Workflow Builder + save: The function for handling configuration in the Workflow Builder + execute: The function for handling the step execution + """ + warnings.warn( + ( + "Steps from apps for legacy workflows are now deprecated. " + "Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/" + ), + category=DeprecationWarning, + ) + step = callback_id + if isinstance(callback_id, (str, Pattern)): + step = AsyncWorkflowStep( + callback_id=callback_id, + edit=edit, # type: ignore[arg-type] + save=save, # type: ignore[arg-type] + execute=execute, # type: ignore[arg-type] + base_logger=self._base_logger, + ) + elif isinstance(step, AsyncWorkflowStepBuilder): + step = step.build(base_logger=self._base_logger) + elif not isinstance(step, AsyncWorkflowStep): + raise BoltError(f"Invalid step object ({type(step)})") + + self.use(AsyncWorkflowStepMiddleware(step)) + + # ------------------------- + # global error handler + + def error( + self, func: Callable[..., Awaitable[Optional[BoltResponse]]] + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + """Updates the global error handler. This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.error + async def custom_error_handler(error, body, logger): + logger.exception(f"Error: {error}") + logger.info(f"Request body: {body}") + + # Pass a function to this method + app.error(custom_error_handler) + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + func: The function that is supposed to be executed + when getting an unhandled error in Bolt app. + """ + if not is_callable_coroutine(func): + name = get_name_for_callable(func) + raise BoltError(error_listener_function_must_be_coro_func(name)) + self._async_listener_runner.listener_error_handler = AsyncCustomListenerErrorHandler( + logger=self._framework_logger, + func=func, + ) + self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler( + logger=self._framework_logger, + func=func, + ) + return func # ------------------------- # events def event( self, - event: Union[str, Pattern, Dict[str, str]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.event(event, True) - return self._register_listener( - func, primary_matcher, matchers, middleware, True - ) + event: Union[ + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], + ], + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new event listener. This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.event("team_join") + async def ask_for_introduction(event, say): + welcome_channel_id = "C12345" + user_id = event["user"] + text = f"Welcome to the team, <@{user_id}>! :tada: You can introduce yourself in this channel." + await say(text=text, channel=welcome_channel_id) + + # Pass a function to this method + app.event("team_join")(ask_for_introduction) + + Refer to https://docs.slack.dev/apis/events-api/ for details of Events API. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + event: The conditions that match a request payload. + If you pass a dict for this, you can have type, subtype in the constraint. + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + 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__ def message( self, - keyword: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - matchers = matchers if matchers else [] - - def __call__(func): - primary_matcher = builtin_matchers.event("message", True) - - async def keyword_matcher(payload) -> bool: - text: Optional[str] = payload.get("event", {}).get("text", {}) - if text: - if isinstance(keyword, Pattern): - return keyword.match(text) # type: ignore - elif isinstance(keyword, str): - return keyword in text - return False + keyword: Union[str, Pattern] = "", + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new message event listener. This method can be used as either a decorator or a method. + Check the `App#event` method's docstring for details. + + # Use this method as a decorator + @app.message(":wave:") + async def say_hello(message, say): + user = message['user'] + await say(f"Hi there, <@{user}>!") + + # Pass a function to this method + app.message(":wave:")(say_hello) + + Refer to https://docs.slack.dev/reference/events/message/ for details of `message` events. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + keyword: The keyword to match + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", + ), + } + primary_matcher = builtin_matchers.message_event( + constraints=constraints, + keyword=keyword, + asyncio=True, + base_logger=self._base_logger, + ) + 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) - matchers.insert(0, keyword_matcher) + return __call__ - return self._register_listener( - func, primary_matcher, matchers, middleware, True + def function( + self, + callback_id: Union[str, Pattern], + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + auto_acknowledge: bool = True, + ack_timeout: int = 3, + ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: + """Registers a new Function listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.function("reverse") + async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail): + try: + await ack() + string_to_reverse = inputs["stringToReverse"] + await complete({"reverseString": string_to_reverse[::-1]}) + except Exception as e: + await fail(f"Cannot reverse string (error: {e})") + raise e + + # Pass a function to this method + app.function("reverse")(reverse_string) + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + callback_id: The callback id to identify the function + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + if auto_acknowledge is True: + if ack_timeout != 3: + self._framework_logger.warning(warning_ack_timeout_has_no_effect(callback_id, ack_timeout)) + + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.function_executed( + callback_id=callback_id, base_logger=self._base_logger, asyncio=True ) + return self._register_listener(functions, primary_matcher, matchers, middleware, auto_acknowledge, ack_timeout) return __call__ @@ -445,12 +997,38 @@ async def keyword_matcher(payload) -> bool: def command( self, command: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.command(command, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new slash command listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.command("/echo") + async def repeat_text(ack, say, command): + # Acknowledge command request + await ack() + await say(f"{command['text']}") + + # Pass a function to this method + app.command("/echo")(repeat_text) + + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details of Slash Commands. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + command: The conditions that match a request payload + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.command(command, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -460,36 +1038,74 @@ def __call__(func): def shortcut( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.shortcut(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new shortcut listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.shortcut("open_modal") + async def open_modal(ack, body, client): + # Acknowledge the command request + await ack() + # Call views_open with the built-in client + await client.views_open( + # Pass a valid trigger_id within 3 seconds of receiving it + trigger_id=body["trigger_id"], + # View payload + view={ ... } + ) + + # Pass a function to this method + app.shortcut("open_modal")(open_modal) + + Refer to https://docs.slack.dev/interactivity/implementing-shortcuts/ for details about Shortcuts. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + constraints: The conditions that match a request payload. + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.shortcut(constraints, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def global_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.global_shortcut(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new global shortcut listener.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.global_shortcut(callback_id, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def message_shortcut( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.message_shortcut(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new message shortcut listener.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.message_shortcut(callback_id, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -499,60 +1115,102 @@ def __call__(func): def action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.action(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new action listener. This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.action("approve_button") + async def update_message(ack): + await ack() + + # Pass a function to this method + app.action("approve_button")(update_message) + + * Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for actions in `blocks`. + * Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for actions in `attachments`. + * Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for actions in dialogs. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + constraints: The conditions that match a request payload + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.action(constraints, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def block_action( self, - action_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.block_action(action_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `block_actions` action listener. + Refer to https://docs.slack.dev/reference/interaction-payloads/block_actions-payload/ for details. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.block_action(constraints, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def attachment_action( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.attachment_action(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `interactive_message` action listener. + Refer to https://docs.slack.dev/legacy/legacy-messaging/legacy-message-buttons/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.attachment_action(callback_id, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def dialog_submission( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.dialog_submission(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `dialog_submission` listener. + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.dialog_submission(callback_id, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def dialog_cancellation( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.dialog_cancellation(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `dialog_submission` listener. + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.dialog_cancellation(callback_id, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -562,36 +1220,82 @@ def __call__(func): def view( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.view(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `view_submission`/`view_closed` event listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.view("view_1") + async def handle_submission(ack, body, client, view): + # Assume there's an input block with `block_c` as the block_id and `dreamy_input` + hopes_and_dreams = view["state"]["values"]["block_c"]["dreamy_input"] + user = body["user"]["id"] + # Validate the inputs + errors = {} + if hopes_and_dreams is not None and len(hopes_and_dreams) <= 5: + errors["block_c"] = "The value must be longer than 5 characters" + if len(errors) > 0: + await ack(response_action="errors", errors=errors) + return + # Acknowledge the view_submission event and close the modal + await ack() + # Do whatever you want with the input data - here we're saving it to a DB + + # Pass a function to this method + app.view("view_1")(handle_submission) + + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload for details of payloads. + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + constraints: The conditions that match a request payload + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.view(constraints, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def view_submission( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.view_submission(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `view_submission` listener. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_submission for + details. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.view_submission(constraints, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def view_closed( self, constraints: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.view_closed(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `view_closed` listener. + Refer to https://docs.slack.dev/reference/interaction-payloads/view-interactions-payload/#view_closed for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.view_closed(constraints, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -601,85 +1305,195 @@ def __call__(func): def options( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.options(constraints, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new options listener. + This method can be used as either a decorator or a method. + + # Use this method as a decorator + @app.options("menu_selection") + async def show_menu_options(ack): + options = [ + { + "text": {"type": "plain_text", "text": "Option 1"}, + "value": "1-1", + }, + { + "text": {"type": "plain_text", "text": "Option 2"}, + "value": "1-2", + }, + ] + await ack(options=options) + + # Pass a function to this method + app.options("menu_selection")(show_menu_options) + + Refer to the following documents for details: + + * https://docs.slack.dev/reference/block-kit/block-elements/select-menu-element#external_select + * https://docs.slack.dev/reference/block-kit/block-elements/multi-select-menu-element#external_multi_select + + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + + Args: + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.options(constraints, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def block_suggestion( self, action_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.block_suggestion(action_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `block_suggestion` listener.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.block_suggestion(action_id, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ def dialog_suggestion( self, callback_id: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ): - def __call__(func): - primary_matcher = builtin_matchers.dialog_suggestion(callback_id, True) - return self._register_listener(func, primary_matcher, matchers, middleware) + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + """Registers a new `dialog_suggestion` listener. + Refer to https://docs.slack.dev/legacy/legacy-dialogs/ for details.""" + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.dialog_suggestion(callback_id, asyncio=True, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ + # ------------------------- + # built-in listener functions + + def default_tokens_revoked_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_tokens_revoked_events + + def default_app_uninstalled_event_listener( + self, + ) -> Callable[..., Awaitable[Optional[BoltResponse]]]: + if self._async_tokens_revocation_listeners is None: + raise BoltError(error_installation_store_required_for_builtin_listeners()) + return self._async_tokens_revocation_listeners.handle_app_uninstalled_events + + def enable_token_revocation_listeners(self) -> None: + self.event("tokens_revoked")(self.default_tokens_revoked_event_listener()) + self.event("app_uninstalled")(self.default_app_uninstalled_event_listener()) + # ------------------------- def _init_context(self, req: AsyncBoltRequest): - req.context["logger"] = get_bolt_app_logger(self.name) + req.context["logger"] = get_bolt_app_logger(app_name=self.name, base_logger=self._base_logger) req.context["token"] = self._token - req.context["client"] = self._async_client + # Prior to version 1.15, when the token is static, self._client was passed to `req.context`. + # The intention was to avoid creating a new instance per request + # in the interest of runtime performance/memory footprint optimization. + # However, developers may want to replace the token held by req.context.client in some situations. + # In this case, this behavior can result in thread-unsafe data modification on `self._client`. + # (`self._client` a.k.a. `app.client` is a singleton object per an App instance) + # Thus, we've changed the behavior to create a new instance per request regardless of token argument + # in the App initialization starting v1.15. + # The overhead brought by this change is slight so that we believe that it is ignorable in any cases. + client_per_request: AsyncWebClient = AsyncWebClient( + token=self._token, # this can be None, and it can be set later on + base_url=self._async_client.base_url, + timeout=self._async_client.timeout, + ssl=self._async_client.ssl, + proxy=self._async_client.proxy, + session=self._async_client.session, + trust_env_in_session=self._async_client.trust_env_in_session, + headers=self._async_client.headers, + team_id=req.context.team_id, + logger=self._async_client.logger, + retry_handlers=( + self._async_client.retry_handlers.copy() if self._async_client.retry_handlers is not None else None + ), + ) + req.context["client"] = client_per_request + + # Most apps do not need this "listener_runner" instance. + # It is intended for apps that start lazy listeners from their custom global middleware. + req.context["listener_runner"] = self.listener_runner + + @staticmethod + def _to_listener_functions( + kwargs: dict, + ) -> Optional[Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + if kwargs: + functions = [kwargs["ack"]] + for sub in kwargs["lazy"]: + functions.append(sub) + return functions + return None def _register_listener( self, - func: Callable[..., Awaitable[BoltResponse]], + functions: Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]], primary_matcher: AsyncListenerMatcher, - matchers: Optional[List[Callable[..., Awaitable[bool]]]], - middleware: Optional[List[Union[Callable, AsyncMiddleware]]], + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]], + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, - ) -> None: - - if not inspect.iscoroutinefunction(func): - name = func.__name__ - raise BoltError( - f"The listener function ({name}) is not a coroutine function." - ) - - listener_matchers = [ - AsyncCustomListenerMatcher(app_name=self.name, func=f) - for f in (matchers or []) + ack_timeout: int = 3, + ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + value_to_return = None + if not isinstance(functions, list): + functions = list(functions) + if len(functions) == 1: + # In the case where the function is registered using decorator, + # the registration should return the original function. + value_to_return = functions[0] + + for func in functions: + if not is_callable_coroutine(func): + name = get_name_for_callable(func) + raise BoltError(error_listener_function_must_be_coro_func(name)) + + listener_matchers: List[AsyncListenerMatcher] = [ + AsyncCustomListenerMatcher(app_name=self.name, func=f, base_logger=self._base_logger) for f in (matchers or []) ] listener_matchers.insert(0, primary_matcher) listener_middleware = [] for m in middleware or []: if isinstance(m, AsyncMiddleware): listener_middleware.append(m) - elif isinstance(m, Callable) and inspect.iscoroutinefunction(m): - listener_middleware.append( - AsyncCustomMiddleware(app_name=self.name, func=m) - ) + elif callable(m) and is_callable_coroutine(m): + listener_middleware.append(AsyncCustomMiddleware(app_name=self.name, func=m, base_logger=self._base_logger)) else: - raise ValueError( - f"async function is required for AsyncApp's listener middleware: {type(m)}" - ) + raise ValueError(error_unexpected_listener_middleware(type(m))) self._async_listeners.append( AsyncCustomListener( app_name=self.name, - func=func, + ack_function=functions.pop(0), + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, + base_logger=self._base_logger, ) ) + + return value_to_return diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index 642f7f37e..f21d35932 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -1,38 +1,58 @@ import logging +from typing import Optional, TYPE_CHECKING from aiohttp import web from slack_bolt.adapter.aiohttp import to_bolt_request, to_aiohttp_response from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_boot_message + +if TYPE_CHECKING: + from slack_bolt.app.async_app import AsyncApp class AsyncSlackAppServer: + port: int + path: str + host: str + bolt_app: "AsyncApp" + web_app: web.Application + def __init__( - self, port: int, path: str, app, # AsyncApp + self, + port: int, + path: str, + app: "AsyncApp", + host: Optional[str] = None, ): - self._port = port - self._endpoint_path = path - self._bolt_app: "AsyncApp" = app + """Standalone AIOHTTP Web Server. + Refer to https://docs.aiohttp.org/en/stable/web.html for details of AIOHTTP. + Args: + port: The port to listen on + path: The path to receive incoming requests from Slack + app: The `AsyncApp` instance that is used for processing requests + host: The hostname to serve the web endpoints. (Default: 0.0.0.0) + """ + self.port = port + self.path = path + self.host = host if host is not None else "0.0.0.0" + self.bolt_app: "AsyncApp" = app self.web_app = web.Application() - self._bolt_oauth_flow = self._bolt_app.oauth_flow + self._bolt_oauth_flow = self.bolt_app.oauth_flow if self._bolt_oauth_flow: self.web_app.add_routes( [ - web.get( - self._bolt_oauth_flow.install_path, self.handle_get_requests - ), + web.get(self._bolt_oauth_flow.install_path, self.handle_get_requests), web.get( self._bolt_oauth_flow.redirect_uri_path, self.handle_get_requests, ), - web.post(self._endpoint_path, self.handle_post_requests), + web.post(self.path, self.handle_post_requests), ] ) else: - self.web_app.add_routes( - [web.post(self._endpoint_path, self.handle_post_requests)] - ) + self.web_app.add_routes([web.post(self.path, self.handle_post_requests)]) async def handle_get_requests(self, request: web.Request) -> web.Response: oauth_flow = self._bolt_oauth_flow @@ -51,17 +71,19 @@ async def handle_get_requests(self, request: web.Request) -> web.Response: return web.Response(status=404) async def handle_post_requests(self, request: web.Request) -> web.Response: - if self._endpoint_path != request.path: + if self.path != request.path: return web.Response(status=404) bolt_req = await to_bolt_request(request) - bolt_resp: BoltResponse = await self._bolt_app.async_dispatch(bolt_req) + bolt_resp: BoltResponse = await self.bolt_app.async_dispatch(bolt_req) return await to_aiohttp_response(bolt_resp) - def start(self): - if self._bolt_app.logger.level > logging.INFO: - print("⚡️ Bolt app is running!") + def start(self, host: Optional[str] = None) -> None: + """Starts a new web server process.""" + if self.bolt_app.logger.level > logging.INFO: + print(get_boot_message()) else: - self._bolt_app.logger.info("⚡️ Bolt app is running!") + self.bolt_app.logger.info(get_boot_message()) - web.run_app(self.web_app, host="0.0.0.0", port=self._port) + _host = host if host is not None else self.host + web.run_app(self.web_app, host=_host, port=self.port) diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index 939d1982e..f95d952aa 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -1,8 +1,80 @@ -from .app.async_app import AsyncApp # noqa -from .context.ack.async_ack import AsyncAck # noqa -from .context.async_context import AsyncBoltContext # noqa -from .context.respond.async_respond import AsyncRespond # noqa -from .context.say.async_say import AsyncSay # noqa -from .listener.async_listener import AsyncListener # noqa -from .listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher # noqa -from .request.async_request import AsyncBoltRequest # noqa +"""Module for creating asyncio based apps + +### Creating an async app + +If you'd prefer to build your app with [asyncio](https://docs.python.org/3/library/asyncio.html), you can import the [AIOHTTP](https://docs.aiohttp.org/en/stable/) library and call the `AsyncApp` constructor. Within async apps, you can use the async/await pattern. + +```bash +# Python 3.7+ required +python -m venv .venv +source .venv/bin/activate + +pip install -U pip +# aiohttp is required +pip install slack_bolt aiohttp +``` + +In async apps, all middleware/listeners must be async functions. When calling utility methods (like `ack` and `say`) within these functions, it's required to use the `await` keyword. + +```python +# Import the async app instead of the regular one +from slack_bolt.async_app import AsyncApp + +app = AsyncApp() + +@app.event("app_mention") +async def event_test(body, say, logger): + logger.info(body) + await say("What's up?") + +@app.command("/hello-bolt-python") +async def command(ack, body, respond): + await ack() + await respond(f"Hi <@{body['user_id']}>!") + +if __name__ == "__main__": + app.start(3000) +``` + +If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at the built-in adapters and their examples. + +* [The Bolt app examples](https://github.com/slackapi/bolt-python/tree/main/examples) +* [The built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) +Apps can be run the same way as the synchronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [examples](https://github.com/slackapi/bolt-python/tree/main/examples). + +Refer to `slack_bolt.app.async_app` for more details. +""" # noqa: E501 + +from .app.async_app import AsyncApp +from .context.ack.async_ack import AsyncAck +from .context.async_context import AsyncBoltContext +from .context.respond.async_respond import AsyncRespond +from .context.say.async_say import AsyncSay +from .listener.async_listener import AsyncListener +from .listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher +from .request.async_request import AsyncBoltRequest +from .middleware.assistant.async_assistant import AsyncAssistant +from .context.set_status.async_set_status import AsyncSetStatus +from .context.set_title.async_set_title import AsyncSetTitle +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", + "AsyncAck", + "AsyncBoltContext", + "AsyncRespond", + "AsyncSay", + "AsyncSayStream", + "AsyncListener", + "AsyncCustomListenerMatcher", + "AsyncBoltRequest", + "AsyncAssistant", + "AsyncSetStatus", + "AsyncSetTitle", + "AsyncSetSuggestedPrompts", + "AsyncGetThreadContext", + "AsyncSaveThreadContext", +] diff --git a/slack_bolt/auth/__init__.py b/slack_bolt/auth/__init__.py deleted file mode 100644 index c08967fc1..000000000 --- a/slack_bolt/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .result import AuthorizationResult diff --git a/slack_bolt/auth/result.py b/slack_bolt/auth/result.py deleted file mode 100644 index 75cb1c7c1..000000000 --- a/slack_bolt/auth/result.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Optional - - -class AuthorizationResult(dict): - enterprise_id: Optional[str] - team_id: Optional[str] - bot_id: str - bot_user_id: str - bot_token: str - user_id: Optional[str] - user_token: Optional[str] - - def __init__( - self, - *, - enterprise_id: Optional[str], - team_id: Optional[str], - # bot - bot_user_id: str, - bot_id: str, - bot_token: str, - # user - user_id: Optional[str] = None, - user_token: Optional[str] = None, - ): - self.enterprise_id: Optional[str] = enterprise_id - self.team_id: Optional[str] = team_id - # bot - self.bot_user_id: str = bot_user_id - self.bot_id: str = bot_id - self.bot_token: str = bot_token - # user - self.user_id: Optional[str] = user_id - self.user_token: Optional[str] = user_token diff --git a/slack_bolt/authorization/__init__.py b/slack_bolt/authorization/__init__.py new file mode 100644 index 000000000..4b80a93bb --- /dev/null +++ b/slack_bolt/authorization/__init__.py @@ -0,0 +1,11 @@ +"""Authorization is the process of determining which Slack credentials should be available +while processing an incoming Slack event. + +Refer to https://docs.slack.dev/tools/bolt-python/concepts/authorization for details. +""" + +from .authorize_result import AuthorizeResult + +__all__ = [ + "AuthorizeResult", +] diff --git a/slack_bolt/authorization/async_authorize.py b/slack_bolt/authorization/async_authorize.py new file mode 100644 index 000000000..f3303e429 --- /dev/null +++ b/slack_bolt/authorization/async_authorize.py @@ -0,0 +1,377 @@ +from logging import Logger +from typing import Optional, Callable, Awaitable, Dict, Any, Sequence + +from slack_sdk.errors import SlackApiError, SlackTokenRotationError +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.oauth.token_rotation.async_rotator import AsyncTokenRotator +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.authorization.async_authorize_args import AsyncAuthorizeArgs +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError +from slack_bolt.util.utils import get_arg_names_of_callable + + +class AsyncAuthorize: + """This provides authorize function that returns AuthorizeResult + for an incoming request from Slack.""" + + def __init__(self): + pass + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, + ) -> Optional[AuthorizeResult]: + raise NotImplementedError() + + +class AsyncCallableAuthorize(AsyncAuthorize): + """When you pass the authorize argument in AsyncApp constructor, + This authorize implementation will be used. + """ + + def __init__(self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizeResult]]): + self.logger = logger + self.func = func + self.arg_names = get_arg_names_of_callable(func) + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, + ) -> Optional[AuthorizeResult]: + try: + all_available_args = { + "args": AsyncAuthorizeArgs( + context=context, + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + ), + "logger": context.logger, + "client": context.client, + "context": context, + "enterprise_id": enterprise_id, + "team_id": team_id, + "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, + } + for k, v in context.items(): + if k not in all_available_args: + all_available_args[k] = v + + kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in self.arg_names} + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + auth_result: Optional[AuthorizeResult] = await self.func(**kwargs) + if auth_result is None: + return auth_result + + if isinstance(auth_result, AuthorizeResult): + return auth_result + else: + raise ValueError(f"Unexpected returned value from authorize function (type: {type(auth_result)})") + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None + + +class AsyncInstallationStoreAuthorize(AsyncAuthorize): + """If you use the OAuth flow settings, this authorize implementation will be used. + As long as your own InstallationStore (or the built-in ones) works as you expect, + you can expect that the authorize layer should work for you without any customization. + """ + + authorize_result_cache: Dict[str, AuthorizeResult] + bot_only: bool + user_token_resolution: str + find_installation_available: Optional[bool] + find_bot_available: Optional[bool] + token_rotator: Optional[AsyncTokenRotator] + + _config_error_message: str = "AsyncInstallationStore with client_id/client_secret are required for token rotation" + + def __init__( + self, + *, + logger: Logger, + installation_store: AsyncInstallationStore, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + token_rotation_expiration_minutes: Optional[int] = None, + # For v1.0.x compatibility and people who still want its simplicity + # use only InstallationStore#find_bot(enterprise_id, team_id) + bot_only: bool = False, + cache_enabled: bool = False, + client: Optional[AsyncWebClient] = None, + # Since v1.27, user token resolution can be actor ID based when the mode is enabled + user_token_resolution: str = "authed_user", + ): + self.logger = logger + self.installation_store = installation_store + self.bot_only = bot_only + self.user_token_resolution = user_token_resolution + self.cache_enabled = cache_enabled + self.authorize_result_cache = {} + self.find_installation_available = None + self.find_bot_available = None + if client_id is not None and client_secret is not None: + self.token_rotator = AsyncTokenRotator( + client_id=client_id, + client_secret=client_secret, + client=client, + ) + else: + self.token_rotator = None + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes or 120 + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, + ) -> Optional[AuthorizeResult]: + + if self.find_installation_available is None: + self.find_installation_available = hasattr(self.installation_store, "async_find_installation") + if self.find_bot_available is None: + self.find_bot_available = hasattr(self.installation_store, "async_find_bot") + + bot_token: Optional[str] = None + user_token: Optional[str] = None + bot_scopes: Optional[Sequence[str]] = None + user_scopes: Optional[Sequence[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None + + if not self.bot_only and self.find_installation_available: + # Since v1.1, this is the default way. + # If you want to use find_bot / delete_bot only, you can set bot_only as True. + try: + # Note that this is the latest information for the org/workspace. + # The installer may not be the user associated with this incoming request. + latest_bot_installation = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + # If the user_token in the latest_installation is not for the user associated with this request, + # we'll fetch a different installation for the user below + # The example use cases are: + # - The app's installation requires both bot and user tokens + # - The app has two installation paths 1) bot installation 2) individual user authorization + if latest_bot_installation is not None: + # Save the latest bot token + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None + + if latest_bot_installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + user_token = None + user_scopes = None + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = None + + # try to fetch the request user's installation + # to reflect the user's access token if exists + # try to fetch the request user's installation + # to reflect the user's access token if exists + if self.user_token_resolution == "actor": + if actor_enterprise_id is not None or actor_team_id is not None: + # Note that actor_team_id can be absent for app_mention events + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=actor_enterprise_id, + team_id=actor_team_id, + user_id=actor_user_id, + is_enterprise_install=None, + ) + else: + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if this_user_installation is not None: + user_token = this_user_installation.user_token + user_scopes = this_user_installation.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes + + # If token rotation is enabled, running rotation may be needed here + refreshed = await self._rotate_and_save_tokens_if_necessary(this_user_installation) + if refreshed is not None: + user_token = refreshed.user_token + user_scopes = refreshed.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): + # If latest_installation has a bot token, we never overwrite the value + bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes + + # If token rotation is enabled, running rotation may be needed here + refreshed = await self._rotate_and_save_tokens_if_necessary(latest_bot_installation) + if refreshed is not None: + bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes + if this_user_installation is None: + # Only when we don't have `this_user_installation` here, + # the `user_token` is for the user associated with this request + user_token = refreshed.user_token + user_scopes = refreshed.user_scopes + + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None + except NotImplementedError as _: + self.find_installation_available = False + + if ( + # If you intentionally use only `find_bot` / `delete_bot`, + self.bot_only + # If the `find_installation` method is not available, + or not self.find_installation_available + # If the `find_installation` method did not return data and find_bot method is available, + or (self.find_bot_available is True and bot_token is None and user_token is None) + ): + try: + bot: Optional[Bot] = await self.installation_store.async_find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is not None: + bot_token = bot.bot_token + bot_scopes = bot.bot_scopes + if bot.bot_refresh_token is not None: + # Token rotation + if self.token_rotator is None: + raise BoltError(self._config_error_message) + refreshed_bot = await self.token_rotator.perform_bot_token_rotation( + bot=bot, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed_bot is not None: + await self.installation_store.async_save_bot(refreshed_bot) + bot_token = refreshed_bot.bot_token + bot_scopes = refreshed_bot.bot_scopes + + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None + except NotImplementedError as _: + self.find_bot_available = False + except Exception as e: + self.logger.info(f"Failed to call find_bot method: {e}") + + token: Optional[str] = bot_token or user_token + if token is None: + # No valid token was found + self._debug_log_for_not_found(enterprise_id, team_id) + return None + + # Check cache to see if the bot object already exists + if self.cache_enabled and token in self.authorize_result_cache: + return self.authorize_result_cache[token] + + try: + auth_test_api_response = await context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = await context.client.auth_test(token=user_token) + authorize_result = AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, + bot_token=bot_token, + user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, + ) + if self.cache_enabled: + self.authorize_result_cache[token] = authorize_result + return authorize_result + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None + + # ------------------------------------------------ + + def _debug_log_for_not_found(self, enterprise_id: Optional[str], team_id: Optional[str]): + self.logger.debug("No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}") + + async def _rotate_and_save_tokens_if_necessary(self, installation: Optional[Installation]) -> Optional[Installation]: + if installation is None or (installation.user_refresh_token is None and installation.bot_refresh_token is None): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[Installation] = await self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + await self.installation_store.async_save(refreshed) + return refreshed diff --git a/slack_bolt/authorization/async_authorize_args.py b/slack_bolt/authorization/async_authorize_args.py new file mode 100644 index 000000000..08af16766 --- /dev/null +++ b/slack_bolt/authorization/async_authorize_args.py @@ -0,0 +1,38 @@ +from logging import Logger +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext + + +class AsyncAuthorizeArgs: + context: AsyncBoltContext + logger: Logger + client: AsyncWebClient + enterprise_id: Optional[str] + team_id: Optional[str] + user_id: Optional[str] + + def __init__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + ): + """The full list of the arguments passed to `authorize` function. + + Args: + context: The request context + enterprise_id: The Organization ID (Enterprise Grid) + team_id: The workspace ID + user_id: The request user ID + """ + self.context = context + self.logger = context.logger + self.client = context.client + self.enterprise_id = enterprise_id + self.team_id = team_id + self.user_id = user_id diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py new file mode 100644 index 000000000..afed6fa8b --- /dev/null +++ b/slack_bolt/authorization/authorize.py @@ -0,0 +1,374 @@ +from logging import Logger +from typing import Optional, Callable, Dict, Any, Sequence + +from slack_sdk.errors import SlackApiError, SlackTokenRotationError +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store.models.bot import Bot +from slack_sdk.oauth.installation_store.models.installation import Installation +from slack_sdk.oauth.token_rotation.rotator import TokenRotator +from slack_sdk.web import WebClient + +from slack_bolt.authorization.authorize_args import AuthorizeArgs +from slack_bolt.authorization.authorize_result import AuthorizeResult +from slack_bolt.context.context import BoltContext +from slack_bolt.error import BoltError +from slack_bolt.util.utils import get_arg_names_of_callable + + +class Authorize: + """This provides authorize function that returns AuthorizeResult + for an incoming request from Slack.""" + + def __init__(self): + pass + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, + ) -> Optional[AuthorizeResult]: + raise NotImplementedError() + + +class CallableAuthorize(Authorize): + """When you pass the `authorize` argument in AsyncApp constructor, + This `authorize` implementation will be used. + """ + + def __init__( + self, + *, + logger: Logger, + func: Callable[..., AuthorizeResult], + ): + self.logger = logger + self.func = func + self.arg_names = get_arg_names_of_callable(func) + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, + ) -> Optional[AuthorizeResult]: + try: + all_available_args = { + "args": AuthorizeArgs( + context=context, + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + ), + "logger": context.logger, + "client": context.client, + "context": context, + "enterprise_id": enterprise_id, + "team_id": team_id, + "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, + } + for k, v in context.items(): + if k not in all_available_args: + all_available_args[k] = v + + kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in self.arg_names} + found_arg_names = kwargs.keys() + for name in self.arg_names: + if name not in found_arg_names: + self.logger.warning(f"{name} is not a valid argument") + kwargs[name] = None + + auth_result = self.func(**kwargs) + if auth_result is None: + return auth_result + + if isinstance(auth_result, AuthorizeResult): + return auth_result + else: + raise ValueError(f"Unexpected returned value from authorize function (type: {type(auth_result)})") + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None + + +class InstallationStoreAuthorize(Authorize): + """If you use the OAuth flow settings, this `authorize` implementation will be used. + As long as your own InstallationStore (or the built-in ones) works as you expect, + you can expect that the `authorize` layer should work for you without any customization. + """ + + authorize_result_cache: Dict[str, AuthorizeResult] + bot_only: bool + user_token_resolution: str + find_installation_available: bool + find_bot_available: bool + token_rotator: Optional[TokenRotator] + + _config_error_message: str = "InstallationStore with client_id/client_secret are required for token rotation" + + def __init__( + self, + *, + logger: Logger, + installation_store: InstallationStore, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + token_rotation_expiration_minutes: Optional[int] = None, + # For v1.0.x compatibility and people who still want its simplicity + # use only InstallationStore#find_bot(enterprise_id, team_id) + bot_only: bool = False, + cache_enabled: bool = False, + client: Optional[WebClient] = None, + # Since v1.27, user token resolution can be actor ID based when the mode is enabled + user_token_resolution: str = "authed_user", + ): + self.logger = logger + self.installation_store = installation_store + self.bot_only = bot_only + self.user_token_resolution = user_token_resolution + self.cache_enabled = cache_enabled + self.authorize_result_cache = {} + self.find_installation_available = hasattr(installation_store, "find_installation") + self.find_bot_available = hasattr(installation_store, "find_bot") + if client_id is not None and client_secret is not None: + self.token_rotator = TokenRotator( + client_id=client_id, + client_secret=client_secret, + client=client, + ) + else: + self.token_rotator = None + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes or 120 + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + # actor_* can be used only when user_token_resolution: "actor" is set + actor_enterprise_id: Optional[str] = None, + actor_team_id: Optional[str] = None, + actor_user_id: Optional[str] = None, + ) -> Optional[AuthorizeResult]: + + bot_token: Optional[str] = None + user_token: Optional[str] = None + bot_scopes: Optional[Sequence[str]] = None + user_scopes: Optional[Sequence[str]] = None + latest_bot_installation: Optional[Installation] = None + this_user_installation: Optional[Installation] = None + + if not self.bot_only and self.find_installation_available: + # Since v1.1, this is the default way. + # If you want to use find_bot / delete_bot only, you can set bot_only as True. + try: + # Note that this is the latest information for the org/workspace. + # The installer may not be the user associated with this incoming request. + latest_bot_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + # If the user_token in the latest_installation is not for the user associated with this request, + # we'll fetch a different installation for the user below. + # The example use cases are: + # - The app's installation requires both bot and user tokens + # - The app has two installation paths 1) bot installation 2) individual user authorization + if latest_bot_installation is not None: + # Save the latest bot token + bot_token = latest_bot_installation.bot_token # this still can be None + user_token = latest_bot_installation.user_token # this still can be None + bot_scopes = latest_bot_installation.bot_scopes # this still can be None + user_scopes = latest_bot_installation.user_scopes # this still can be None + + if latest_bot_installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + user_token = None + user_scopes = None + latest_bot_installation.user_token = None + latest_bot_installation.user_refresh_token = None + latest_bot_installation.user_token_expires_at = None + latest_bot_installation.user_scopes = None + + # try to fetch the request user's installation + # to reflect the user's access token if exists + if self.user_token_resolution == "actor": + if actor_enterprise_id is not None or actor_team_id is not None: + # Note that actor_team_id can be absent for app_mention events + this_user_installation = self.installation_store.find_installation( + enterprise_id=actor_enterprise_id, + team_id=actor_team_id, + user_id=actor_user_id, + is_enterprise_install=None, + ) + else: + this_user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if this_user_installation is not None: + user_token = this_user_installation.user_token + user_scopes = this_user_installation.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + bot_scopes = this_user_installation.bot_scopes + + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary(this_user_installation) + if refreshed is not None: + user_token = refreshed.user_token + user_scopes = refreshed.user_scopes + if ( + latest_bot_installation.bot_token is None + # enterprise_id/team_id can be different for Slack Connect channel events + # when enabling user_token_resolution: "actor" + and latest_bot_installation.enterprise_id == this_user_installation.enterprise_id + and latest_bot_installation.team_id == this_user_installation.team_id + ): + # If latest_installation has a bot token, we never overwrite the value + bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes + + # If token rotation is enabled, running rotation may be needed here + refreshed = self._rotate_and_save_tokens_if_necessary(latest_bot_installation) + if refreshed is not None: + bot_token = refreshed.bot_token + bot_scopes = refreshed.bot_scopes + if this_user_installation is None: + # Only when we don't have `this_user_installation` here, + # the `user_token` is for the user associated with this request + user_token = refreshed.user_token + user_scopes = refreshed.user_scopes + + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None + except NotImplementedError as _: + self.find_installation_available = False + + if ( + # If you intentionally use only `find_bot` / `delete_bot`, + self.bot_only + # If the `find_installation` method is not available, + or not self.find_installation_available + # If the `find_installation` method did not return data and find_bot method is available, + or (self.find_bot_available is True and bot_token is None and user_token is None) + ): + try: + bot: Optional[Bot] = self.installation_store.find_bot( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + if bot is not None: + bot_token = bot.bot_token + bot_scopes = bot.bot_scopes + if bot.bot_refresh_token is not None: + # Token rotation + if self.token_rotator is None: + raise BoltError(self._config_error_message) + refreshed_bot = self.token_rotator.perform_bot_token_rotation( + bot=bot, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed_bot is not None: + self.installation_store.save_bot(refreshed_bot) + bot_token = refreshed_bot.bot_token + bot_scopes = refreshed_bot.bot_scopes + + except SlackTokenRotationError as rotation_error: + # When token rotation fails, it is usually unrecoverable + # So, this built-in middleware gives up continuing with the following middleware and listeners + self.logger.error(f"Failed to rotate tokens due to {rotation_error}") + return None + except NotImplementedError as _: + self.find_bot_available = False + except Exception as e: + self.logger.info(f"Failed to call find_bot method: {e}") + + token: Optional[str] = bot_token or user_token + if token is None: + # No valid token was found + self._debug_log_for_not_found(enterprise_id, team_id) + return None + + # Check cache to see if the bot object already exists + if self.cache_enabled and token in self.authorize_result_cache: + return self.authorize_result_cache[token] + + try: + auth_test_api_response = context.client.auth_test(token=token) + user_auth_test_response = None + if user_token is not None and token != user_token: + user_auth_test_response = context.client.auth_test(token=user_token) + authorize_result = AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test_api_response, + user_auth_test_response=user_auth_test_response, + bot_token=bot_token, + user_token=user_token, + bot_scopes=bot_scopes, + user_scopes=user_scopes, + ) + if self.cache_enabled: + self.authorize_result_cache[token] = authorize_result + return authorize_result + except SlackApiError as err: + self.logger.debug( + f"The stored bot token for enterprise_id: {enterprise_id} team_id: {team_id} " + f"is no longer valid. (response: {err.response})" + ) + return None + + # ------------------------------------------------ + + def _debug_log_for_not_found(self, enterprise_id: Optional[str], team_id: Optional[str]): + self.logger.debug("No installation data found " f"for enterprise_id: {enterprise_id} team_id: {team_id}") + + def _rotate_and_save_tokens_if_necessary(self, installation: Optional[Installation]) -> Optional[Installation]: + if installation is None or (installation.user_refresh_token is None and installation.bot_refresh_token is None): + # No need to rotate tokens + return None + + if self.token_rotator is None: + # Token rotation is required but this Bolt app is not properly configured + raise BoltError(self._config_error_message) + + refreshed: Optional[Installation] = self.token_rotator.perform_token_rotation( + installation=installation, + minutes_before_expiration=self.token_rotation_expiration_minutes, + ) + if refreshed is not None: + # Save the refreshed data in database for following requests + self.installation_store.save(refreshed) + return refreshed diff --git a/slack_bolt/authorization/authorize_args.py b/slack_bolt/authorization/authorize_args.py new file mode 100644 index 000000000..2d436b697 --- /dev/null +++ b/slack_bolt/authorization/authorize_args.py @@ -0,0 +1,38 @@ +from logging import Logger +from typing import Optional + +from slack_sdk.web import WebClient + +from slack_bolt.context.context import BoltContext + + +class AuthorizeArgs: + context: BoltContext + logger: Logger + client: WebClient + enterprise_id: Optional[str] + team_id: Optional[str] + user_id: Optional[str] + + def __init__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], # can be None for org-wide installed apps + user_id: Optional[str], + ): + """The full list of the arguments passed to `authorize` function. + + Args: + context: The request context + enterprise_id: The Organization ID (Enterprise Grid) + team_id: The workspace ID + user_id: The request user ID + """ + self.context = context + self.logger = context.logger + self.client = context.client + self.enterprise_id = enterprise_id + self.team_id = team_id + self.user_id = user_id diff --git a/slack_bolt/authorization/authorize_result.py b/slack_bolt/authorization/authorize_result.py new file mode 100644 index 000000000..cbf1a4678 --- /dev/null +++ b/slack_bolt/authorization/authorize_result.py @@ -0,0 +1,109 @@ +from typing import Optional, Sequence, Union + +from slack_sdk.web import SlackResponse + + +class AuthorizeResult(dict): + """Authorize function call result""" + + enterprise_id: Optional[str] + team_id: Optional[str] + team: Optional[str] # since v1.18 + url: Optional[str] # since v1.18 + + bot_id: Optional[str] + bot_user_id: Optional[str] + bot_token: Optional[str] + bot_scopes: Optional[Sequence[str]] # since v1.17 + + user_id: Optional[str] + user: Optional[str] # since v1.18 + user_token: Optional[str] + user_scopes: Optional[Sequence[str]] # since v1.17 + + def __init__( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + team: Optional[str] = None, + url: Optional[str] = None, + # bot + bot_user_id: Optional[str] = None, + bot_id: Optional[str] = None, + bot_token: Optional[str] = None, + bot_scopes: Optional[Union[Sequence[str], str]] = None, + # user + user_id: Optional[str] = None, + user: Optional[str] = None, + user_token: Optional[str] = None, + user_scopes: Optional[Union[Sequence[str], str]] = None, + ): + """ + Args: + enterprise_id: Organization ID (Enterprise Grid) starting with `E` + team_id: Workspace ID starting with `T` + team: Workspace name + url: Workspace slack.com URL + bot_user_id: Bot user's User ID starting with either `U` or `W` + bot_id: Bot ID starting with `B` + bot_token: Bot user access token starting with `xoxb-` + bot_scopes: The scopes associated with the bot token + user_id: The request user ID + user: The request user's name + user_token: User access token starting with `xoxp-` + user_scopes: The scopes associated wth the user token + """ + self["enterprise_id"] = self.enterprise_id = enterprise_id + self["team_id"] = self.team_id = team_id + self["team"] = self.team = team + self["url"] = self.url = url + # bot + self["bot_user_id"] = self.bot_user_id = bot_user_id + self["bot_id"] = self.bot_id = bot_id + self["bot_token"] = self.bot_token = bot_token + if bot_scopes is not None and isinstance(bot_scopes, str): + bot_scopes = [scope.strip() for scope in bot_scopes.split(",")] + self["bot_scopes"] = self.bot_scopes = bot_scopes + # user + self["user_id"] = self.user_id = user_id + self["user"] = self.user = user + self["user_token"] = self.user_token = user_token + if user_scopes is not None and isinstance(user_scopes, str): + user_scopes = [scope.strip() for scope in user_scopes.split(",")] + self["user_scopes"] = self.user_scopes = user_scopes + + @classmethod + def from_auth_test_response( + cls, + *, + bot_token: Optional[str] = None, + user_token: Optional[str] = None, + bot_scopes: Optional[Union[Sequence[str], str]] = None, + user_scopes: Optional[Union[Sequence[str], str]] = None, + auth_test_response: Union[SlackResponse, "AsyncSlackResponse"], # type: ignore[name-defined] + user_auth_test_response: Optional[Union[SlackResponse, "AsyncSlackResponse"]] = None, # type: ignore[name-defined] + ) -> "AuthorizeResult": + bot_user_id: Optional[str] = ( + auth_test_response.get("user_id") if auth_test_response.get("bot_id") is not None else None + ) + user_id: Optional[str] = auth_test_response.get("user_id") if auth_test_response.get("bot_id") is None else None + user_name: Optional[str] = auth_test_response.get("user") + if user_id is None and user_auth_test_response is not None: + user_id = user_auth_test_response.get("user_id") + user_name = user_auth_test_response.get("user") + + return AuthorizeResult( + enterprise_id=auth_test_response.get("enterprise_id"), + team_id=auth_test_response.get("team_id"), + team=auth_test_response.get("team"), + url=auth_test_response.get("url"), + bot_id=auth_test_response.get("bot_id"), + bot_user_id=bot_user_id, + bot_scopes=bot_scopes, + user_id=user_id, + user=user_name, + bot_token=bot_token, + user_token=user_token, + user_scopes=user_scopes, + ) diff --git a/slack_bolt/context/__init__.py b/slack_bolt/context/__init__.py index 885a37073..865825601 100644 --- a/slack_bolt/context/__init__.py +++ b/slack_bolt/context/__init__.py @@ -1,2 +1,13 @@ +"""All listeners have access to a context dictionary, which can be used to enrich events with additional information. +Bolt automatically attaches information that is included in the incoming event, +like `user_id`, `team_id`, `channel_id`, and `enterprise_id`. + +Refer to https://docs.slack.dev/tools/bolt-python/concepts/context for details. +""" + # Don't add async module imports here from .context import BoltContext + +__all__ = [ + "BoltContext", +] diff --git a/slack_bolt/context/ack/__init__.py b/slack_bolt/context/ack/__init__.py index 2f150ce24..dfbb4736f 100644 --- a/slack_bolt/context/ack/__init__.py +++ b/slack_bolt/context/ack/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here from .ack import Ack + +__all__ = [ + "Ack", +] diff --git a/slack_bolt/context/ack/ack.py b/slack_bolt/context/ack/ack.py index 5db225a47..5ebc5f5ad 100644 --- a/slack_bolt/context/ack/ack.py +++ b/slack_bolt/context/ack/ack.py @@ -1,7 +1,8 @@ -from typing import List, Optional, Union +from typing import Optional, Union, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup +from slack_sdk.models.views import View from slack_bolt.context.ack.internals import _set_response from slack_bolt.response.response import BoltResponse @@ -16,16 +17,30 @@ def __init__(self): def __call__( self, text: Union[str, dict] = "", # text: str or whole_response: dict - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + response_type: Optional[str] = None, # in_channel / ephemeral + # block_suggestion / dialog_suggestion + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, + # view_submission + response_action: Optional[str] = None, # errors / update / push / clear + errors: Optional[Dict[str, str]] = None, + view: Optional[Union[dict, View]] = None, ) -> BoltResponse: return _set_response( self, text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + response_type=response_type, options=options, option_groups=option_groups, + response_action=response_action, + errors=errors, + view=view, ) diff --git a/slack_bolt/context/ack/async_ack.py b/slack_bolt/context/ack/async_ack.py index ea36e898a..c12e7b6d2 100644 --- a/slack_bolt/context/ack/async_ack.py +++ b/slack_bolt/context/ack/async_ack.py @@ -1,7 +1,8 @@ -from typing import List, Optional, Union +from typing import Optional, Union, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup +from slack_sdk.models.views import View from slack_bolt.context.ack.internals import _set_response from slack_bolt.response.response import BoltResponse @@ -16,16 +17,30 @@ def __init__(self): async def __call__( self, text: Union[str, dict] = "", # text: str or whole_response: dict - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + response_type: Optional[str] = None, # in_channel / ephemeral + # block_suggestion / dialog_suggestion + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, + # view_submission + response_action: Optional[str] = None, # errors / update / push / clear + errors: Optional[Dict[str, str]] = None, + view: Optional[Union[dict, View]] = None, ) -> BoltResponse: return _set_response( self, text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + response_type=response_type, options=options, option_groups=option_groups, + response_action=response_action, + errors=errors, + view=view, ) diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index c9fdde899..8ea5576c2 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -1,42 +1,78 @@ -from typing import Optional, List, Union, Any +from typing import Optional, Union, Any, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup +from slack_sdk.models.views import View from slack_bolt.error import BoltError from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import convert_to_dict_list +from slack_bolt.util.utils import convert_to_dict_list, convert_to_dict def _set_response( self: Any, text_or_whole_response: Union[str, dict] = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + response_type: Optional[str] = None, # in_channel / ephemeral + # block_suggestion / dialog_suggestion + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, + # view_submission + response_action: Optional[str] = None, + errors: Optional[Union[Dict[str, str], Sequence[Dict[str, str]]]] = None, + view: Optional[Union[dict, View]] = None, ) -> BoltResponse: if isinstance(text_or_whole_response, str): text: str = text_or_whole_response + body: Dict[str, Any] = {"text": text} + if response_type: + body["response_type"] = response_type + if unfurl_links is not None: + body["unfurl_links"] = unfurl_links + if unfurl_media is not None: + body["unfurl_media"] = unfurl_media if attachments and len(attachments) > 0: - self.response = BoltResponse( - status=200, - body={"text": text, "attachments": convert_to_dict_list(attachments),}, - ) + body.update({"text": text, "attachments": convert_to_dict_list(attachments)}) + self.response = BoltResponse(status=200, body=body) elif blocks and len(blocks) > 0: - self.response = BoltResponse( - status=200, body={"text": text, "blocks": convert_to_dict_list(blocks),} - ) - elif options and len(options) > 0: - self.response = BoltResponse( - status=200, body={"options": convert_to_dict_list(options),} - ) - elif option_groups and len(option_groups) > 0: - self.response = BoltResponse( - status=200, body={"option_groups": convert_to_dict_list(option_groups),} - ) + body.update({"text": text, "blocks": convert_to_dict_list(blocks)}) + self.response = BoltResponse(status=200, body=body) + elif options is not None: + body = {"options": convert_to_dict_list(options)} + self.response = BoltResponse(status=200, body=body) + elif option_groups is not None: + body = {"option_groups": convert_to_dict_list(option_groups)} + self.response = BoltResponse(status=200, body=body) + elif response_action: + # These patterns are in response to view_submission requests + if response_action == "errors": + if errors: + self.response = BoltResponse( + status=200, + body={ + "response_action": response_action, + "errors": convert_to_dict(errors), # type: ignore[arg-type] + }, + ) + else: + raise ValueError("errors field is required for response_action: errors") + else: + body = {"response_action": response_action} + if view: + body["view"] = convert_to_dict(view) + self.response = BoltResponse(status=200, body=body) + elif errors: + # dialogs: errors without response_action + body = {"errors": convert_to_dict_list(errors)} # type: ignore[arg-type] + self.response = BoltResponse(status=200, body=body) else: - self.response = BoltResponse(status=200, body=text) + if len(body) == 1 and "text" in body: + self.response = BoltResponse(status=200, body=body["text"]) + else: + self.response = BoltResponse(status=200, body=body) return self.response elif isinstance(text_or_whole_response, dict): body = text_or_whole_response @@ -48,10 +84,18 @@ def _set_response( body["options"] = convert_to_dict_list(body["options"]) if "option_groups" in body: body["option_groups"] = convert_to_dict_list(body["option_groups"]) + if "errors" in body: + if body.get("response_action", "") == "errors": + # modal + body["errors"] = convert_to_dict(body["errors"]) + else: + # dialog + body["errors"] = convert_to_dict_list(body["errors"]) + if "view" in body: + body["view"] = convert_to_dict(body["view"]) + # no modification for response_type, response_action here self.response = BoltResponse(status=200, body=body) return self.response else: - raise BoltError( - f"{text_or_whole_response} (type: {type(text_or_whole_response)}) is unsupported" - ) + raise BoltError(f"{text_or_whole_response} (type: {type(text_or_whole_response)}) is unsupported") diff --git a/slack_bolt/context/assistant/__init__.py b/slack_bolt/context/assistant/__init__.py new file mode 100644 index 000000000..c761cec3a --- /dev/null +++ b/slack_bolt/context/assistant/__init__.py @@ -0,0 +1 @@ +# Don't add async module imports here diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py new file mode 100644 index 000000000..e9614fd8d --- /dev/null +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -0,0 +1,72 @@ +from typing import Optional + +from slack_sdk.web import WebClient +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore +from slack_bolt.context.assistant.thread_context_store.default_store import DefaultAssistantThreadContextStore + + +from slack_bolt.context.context import BoltContext +from slack_bolt.context.say import Say +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_title import SetTitle + + +class AssistantUtilities: + payload: dict + client: WebClient + channel_id: str + thread_ts: str + thread_context_store: AssistantThreadContextStore + + def __init__( + self, + *, + payload: dict, + context: BoltContext, + thread_context_store: Optional[AssistantThreadContextStore] = None, + ): + self.payload = payload + self.client = context.client + self.thread_context_store = thread_context_store or DefaultAssistantThreadContextStore(context) + + if has_channel_id_and_thread_ts(self.payload): + # assistant_thread_started + thread = self.payload["assistant_thread"] + self.channel_id = thread["channel_id"] + self.thread_ts = thread["thread_ts"] + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self.channel_id = self.payload["channel"] + self.thread_ts = self.payload["thread_ts"] + else: + # 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})") + + @property + def set_title(self) -> SetTitle: + return SetTitle(self.client, self.channel_id, self.thread_ts) + + @property + def say(self) -> Say: + def build_metadata() -> Optional[dict]: + thread_context = self.get_thread_context() + if thread_context is not None: + return {"event_type": "assistant_thread_context", "event_payload": thread_context} + return None + + return Say( + self.client, + channel=self.channel_id, + thread_ts=self.thread_ts, + build_metadata=build_metadata, + ) + + @property + def get_thread_context(self) -> GetThreadContext: + return GetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload) + + @property + def save_thread_context(self) -> SaveThreadContext: + return SaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts) diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py new file mode 100644 index 000000000..6acf531ce --- /dev/null +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -0,0 +1,75 @@ +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.assistant.thread_context_store.async_store import ( + AsyncAssistantThreadContextStore, +) + +from slack_bolt.context.assistant.thread_context_store.default_async_store import DefaultAsyncAssistantThreadContextStore + + +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +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_title.async_set_title import AsyncSetTitle + + +class AsyncAssistantUtilities: + payload: dict + client: AsyncWebClient + channel_id: str + thread_ts: str + thread_context_store: AsyncAssistantThreadContextStore + + def __init__( + self, + *, + payload: dict, + context: AsyncBoltContext, + thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + ): + self.payload = payload + self.client = context.client + self.thread_context_store = thread_context_store or DefaultAsyncAssistantThreadContextStore(context) + + if has_channel_id_and_thread_ts(self.payload): + # assistant_thread_started + thread = self.payload["assistant_thread"] + self.channel_id = thread["channel_id"] + self.thread_ts = thread["thread_ts"] + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self.channel_id = self.payload["channel"] + self.thread_ts = self.payload["thread_ts"] + else: + # 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})") + + @property + def set_title(self) -> AsyncSetTitle: + return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) + + @property + def say(self) -> AsyncSay: + return AsyncSay( + self.client, + channel=self.channel_id, + thread_ts=self.thread_ts, + build_metadata=self._build_message_metadata, + ) + + async def _build_message_metadata(self) -> dict: + return { + "event_type": "assistant_thread_context", + "event_payload": await self.get_thread_context(), + } + + @property + def get_thread_context(self) -> AsyncGetThreadContext: + return AsyncGetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload) + + @property + def save_thread_context(self) -> AsyncSaveThreadContext: + return AsyncSaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts) diff --git a/slack_bolt/context/assistant/internals.py b/slack_bolt/context/assistant/internals.py new file mode 100644 index 000000000..ee449c31b --- /dev/null +++ b/slack_bolt/context/assistant/internals.py @@ -0,0 +1,9 @@ +def has_channel_id_and_thread_ts(payload: dict) -> bool: + """Verifies if the given payload has both channel_id and thread_ts under assistant_thread property. + This data pattern is available for assistant_* events. + """ + return ( + payload.get("assistant_thread") is not None + and payload["assistant_thread"].get("channel_id") is not None + and payload["assistant_thread"].get("thread_ts") is not None + ) diff --git a/slack_bolt/context/assistant/thread_context/__init__.py b/slack_bolt/context/assistant/thread_context/__init__.py new file mode 100644 index 000000000..bfa97feeb --- /dev/null +++ b/slack_bolt/context/assistant/thread_context/__init__.py @@ -0,0 +1,13 @@ +from typing import Optional + + +class AssistantThreadContext(dict): + enterprise_id: Optional[str] + team_id: Optional[str] + channel_id: str + + def __init__(self, payload: dict): + dict.__init__(self, **payload) + self.enterprise_id = payload.get("enterprise_id") + self.team_id = payload.get("team_id") + self.channel_id = payload["channel_id"] diff --git a/slack_bolt/context/assistant/thread_context_store/__init__.py b/slack_bolt/context/assistant/thread_context_store/__init__.py new file mode 100644 index 000000000..c761cec3a --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/__init__.py @@ -0,0 +1 @@ +# Don't add async module imports here diff --git a/slack_bolt/context/assistant/thread_context_store/async_store.py b/slack_bolt/context/assistant/thread_context_store/async_store.py new file mode 100644 index 000000000..51c0d6691 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/async_store.py @@ -0,0 +1,11 @@ +from typing import Dict, Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext + + +class AsyncAssistantThreadContextStore: + async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + raise NotImplementedError() + + async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + raise NotImplementedError() diff --git a/slack_bolt/context/assistant/thread_context_store/default_async_store.py b/slack_bolt/context/assistant/thread_context_store/default_async_store.py new file mode 100644 index 000000000..351f558d2 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/default_async_store.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional, List + +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.async_store import ( + AsyncAssistantThreadContextStore, +) + + +class DefaultAsyncAssistantThreadContextStore(AsyncAssistantThreadContextStore): + client: AsyncWebClient + context: AsyncBoltContext + + def __init__(self, context: AsyncBoltContext): + self.client = context.client + self.context = context + + async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None: + await self.client.chat_update( + channel=channel_id, + ts=parent_message["ts"], + text=parent_message["text"], + blocks=parent_message["blocks"], + metadata={ + "event_type": "assistant_thread_context", + "event_payload": context, + }, + ) + + async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None and parent_message.get("metadata"): + if bool(parent_message["metadata"]["event_payload"]): + return AssistantThreadContext(parent_message["metadata"]["event_payload"]) + return None + + async def _retrieve_first_bot_reply(self, channel_id: str, thread_ts: str) -> Optional[dict]: + messages: List[dict] = ( + await self.client.conversations_replies( + channel=channel_id, + ts=thread_ts, + oldest=thread_ts, + include_all_metadata=True, + limit=4, # 2 should be usually enough but buffer for more robustness + ) + ).get("messages", []) + for message in messages: + if message.get("subtype") is None and message.get("user") == self.context.bot_user_id: + return message + return None diff --git a/slack_bolt/context/assistant/thread_context_store/default_store.py b/slack_bolt/context/assistant/thread_context_store/default_store.py new file mode 100644 index 000000000..9b9490737 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/default_store.py @@ -0,0 +1,50 @@ +from typing import Dict, Optional, List + +from slack_bolt.context.context import BoltContext +from slack_sdk import WebClient + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + + +class DefaultAssistantThreadContextStore(AssistantThreadContextStore): + client: WebClient + context: "BoltContext" + + def __init__(self, context: BoltContext): + self.client = context.client + self.context = context + + def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None: + self.client.chat_update( + channel=channel_id, + ts=parent_message["ts"], + text=parent_message["text"], + blocks=parent_message["blocks"], + metadata={ + "event_type": "assistant_thread_context", + "event_payload": context, + }, + ) + + def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None and parent_message.get("metadata"): + if bool(parent_message["metadata"]["event_payload"]): + return AssistantThreadContext(parent_message["metadata"]["event_payload"]) + return None + + def _retrieve_first_bot_reply(self, channel_id: str, thread_ts: str) -> Optional[dict]: + messages: List[dict] = self.client.conversations_replies( + channel=channel_id, + ts=thread_ts, + oldest=thread_ts, + include_all_metadata=True, + limit=4, # 2 should be usually enough but buffer for more robustness + ).get("messages", []) + for message in messages: + if message.get("subtype") is None and message.get("user") == self.context.bot_user_id: + return message + return None diff --git a/slack_bolt/context/assistant/thread_context_store/file/__init__.py b/slack_bolt/context/assistant/thread_context_store/file/__init__.py new file mode 100644 index 000000000..a29f3b2c0 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/file/__init__.py @@ -0,0 +1,37 @@ +import json +from typing import Optional, Dict, Union +from pathlib import Path + +from ..store import AssistantThreadContextStore, AssistantThreadContext + + +class FileAssistantThreadContextStore(AssistantThreadContextStore): + + def __init__( + self, + base_dir: str = str(Path.home()) + "/.bolt-app-assistant-thread-contexts", + ): + self.base_dir = base_dir + self._mkdir(self.base_dir) + + def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + path = f"{self.base_dir}/{channel_id}-{thread_ts}.json" + with open(path, "w") as f: + f.write(json.dumps(context)) + + def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + path = f"{self.base_dir}/{channel_id}-{thread_ts}.json" + try: + with open(path) as f: + data = json.loads(f.read()) + if data.get("channel_id") is not None: + return AssistantThreadContext(data) + except FileNotFoundError: + pass + return None + + @staticmethod + def _mkdir(path: Union[str, Path]): + if isinstance(path, str): + path = Path(path) + path.mkdir(parents=True, exist_ok=True) diff --git a/slack_bolt/context/assistant/thread_context_store/store.py b/slack_bolt/context/assistant/thread_context_store/store.py new file mode 100644 index 000000000..2e29c55df --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/store.py @@ -0,0 +1,11 @@ +from typing import Dict, Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext + + +class AssistantThreadContextStore: + def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + raise NotImplementedError() + + def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + raise NotImplementedError() diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 2c28cec28..94b2b5cbe 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -1,26 +1,216 @@ -from typing import Optional +from typing import Optional, TYPE_CHECKING + +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.base_context import BaseContext +from slack_bolt.context.complete.async_complete import AsyncComplete +from slack_bolt.context.fail.async_fail import AsyncFail from slack_bolt.context.respond.async_respond import AsyncRespond +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.""" + + def to_copyable(self) -> "AsyncBoltContext": + new_dict = {} + for prop_name, prop_value in self.items(): + if prop_name in self.copyable_standard_property_names: + # all the standard properties are copiable + new_dict[prop_name] = prop_value + elif prop_name in self.non_copyable_standard_property_names: + # Do nothing with this property (e.g., listener_runner) + continue + else: + try: + copied_value = create_copy(prop_value) + new_dict[prop_name] = copied_value + except TypeError as te: + self.logger.debug( + f"Skipped setting '{prop_name}' to a copied request for lazy listeners " + f"as it's not possible to make a deep copy (error: {te})" + ) + return AsyncBoltContext(new_dict) + + # The return type is intentionally string to avoid circular imports + @property + def listener_runner(self) -> "AsyncioListenerRunner": + """The properly configured listener_runner that is available for middleware/listeners.""" + return self["listener_runner"] + + @property + def client(self) -> AsyncWebClient: + """The `AsyncWebClient` instance available for this request. + + @app.event("app_mention") + async def handle_events(context): + await context.client.chat_postMessage( + channel=context.channel_id, + text="Thanks!", + ) + + # You can access "client" this way too. + @app.event("app_mention") + async def handle_events(client, context): + await client.chat_postMessage( + channel=context.channel_id, + text="Thanks!", + ) + + Returns: + `AsyncWebClient` instance + """ + if "client" not in self: + self["client"] = AsyncWebClient(token=None) + return self["client"] + @property def ack(self) -> AsyncAck: + """`ack()` function for this request. + + @app.action("button") + async def handle_button_clicks(context): + await context.ack() + + # You can access "ack" this way too. + @app.action("button") + async def handle_button_clicks(ack): + await ack() + + Returns: + Callable `ack()` function + """ if "ack" not in self: self["ack"] = AsyncAck() return self["ack"] @property def say(self) -> AsyncSay: + """`say()` function for this request. + + @app.action("button") + async def handle_button_clicks(context): + await context.ack() + await context.say("Hi!") + + # You can access "ack" this way too. + @app.action("button") + async def handle_button_clicks(ack, say): + await ack() + await say("Hi!") + + Returns: + Callable `say()` function + """ if "say" not in self: self["say"] = AsyncSay(client=self.client, channel=self.channel_id) return self["say"] @property def respond(self) -> Optional[AsyncRespond]: + """`respond()` function for this request. + + @app.action("button") + async def handle_button_clicks(context): + await context.ack() + await context.respond("Hi!") + + # You can access "ack" this way too. + @app.action("button") + async def handle_button_clicks(ack, respond): + await ack() + await respond("Hi!") + + Returns: + Callable `respond()` function + """ if "respond" not in self: - self["respond"] = AsyncRespond(response_url=self.response_url) + self["respond"] = AsyncRespond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"] + + @property + def complete(self) -> AsyncComplete: + """`complete()` function for this request. Once a custom function's state is set to complete, + any outputs the function returns will be passed along to the next step of its housing workflow, + or complete the workflow if the function is the last step in a workflow. Additionally, + any interactivity handlers associated to a function invocation will no longer be invocable. + + @app.function("reverse") + async def handle_button_clicks(ack, complete): + await ack() + await complete(outputs={"stringReverse":"olleh"}) + + @app.function("reverse") + async def handle_button_clicks(context): + await context.ack() + await context.complete(outputs={"stringReverse":"olleh"}) + + Returns: + Callable `complete()` function + """ + if "complete" not in self: + self["complete"] = AsyncComplete(client=self.client, function_execution_id=self.function_execution_id) + return self["complete"] + + @property + def fail(self) -> AsyncFail: + """`fail()` function for this request. Once a custom function's state is set to error, + its housing workflow will be interrupted and any provided error message will be passed + on to the end user through SlackBot. Additionally, any interactivity handlers associated + to a function invocation will no longer be invocable. + + @app.function("reverse") + async def handle_button_clicks(ack, fail): + await ack() + await fail(error="something went wrong") + + @app.function("reverse") + async def handle_button_clicks(context): + await context.ack() + await context.fail(error="something went wrong") + + Returns: + Callable `fail()` function + """ + if "fail" not in self: + self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id) + return self["fail"] + + @property + def set_title(self) -> Optional[AsyncSetTitle]: + return self.get("set_title") + + @property + def set_status(self) -> Optional[AsyncSetStatus]: + return self.get("set_status") + + @property + def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]: + return self.get("set_suggested_prompts") + + @property + 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 ab7c2d596..502febcb8 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -1,43 +1,188 @@ from logging import Logger -from typing import Optional +from typing import Any, Dict, Optional, Tuple -from slack_bolt.auth import AuthorizationResult -from slack_sdk import WebClient +from slack_bolt.authorization import AuthorizeResult class BaseContext(dict): - @property - def authorization_result(self) -> Optional[AuthorizationResult]: - return self.get("authorization_result", None) + """Context object associated with a request from Slack.""" + + copyable_standard_property_names = [ + "logger", + "token", + "enterprise_id", + "is_enterprise_install", + "team_id", + "user_id", + "actor_enterprise_id", + "actor_team_id", + "actor_user_id", + "channel_id", + "thread_ts", + "response_url", + "matches", + "authorize_result", + "function_bot_access_token", + "bot_token", + "bot_id", + "bot_user_id", + "user_token", + "function_execution_id", + "inputs", + "client", + "ack", + "say", + "respond", + "complete", + "fail", + "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. + # Other listener runners do not require the change because they invoke a lazy listener over the network, + # meaning that the context initialization would be done again. + non_copyable_standard_property_names = [ + "listener_runner", + "get_thread_context", + "save_thread_context", + ] + + standard_property_names = copyable_standard_property_names + non_copyable_standard_property_names @property def logger(self) -> Logger: + """The properly configured logger that is available for middleware/listeners.""" return self["logger"] @property def token(self) -> Optional[str]: - return self.get("token", None) + """The (bot/user) token resolved for this request.""" + return self.get("token") @property - def client(self) -> Optional[WebClient]: - return self.get("client", None) + def enterprise_id(self) -> Optional[str]: + """The Enterprise Grid Organization ID of this request.""" + return self.get("enterprise_id") @property - def enterprise_id(self) -> Optional[str]: - return self.get("enterprise_id", None) + def is_enterprise_install(self) -> Optional[bool]: + """True if the request is associated with an Org-wide installation.""" + return self.get("is_enterprise_install") @property - def team_id(self) -> str: - return self["team_id"] + def team_id(self) -> Optional[str]: + """The Workspace ID of this request.""" + return self.get("team_id") @property def user_id(self) -> Optional[str]: - return self.get("user_id", None) + """The user ID associated ith this request.""" + return self.get("user_id") + + @property + def actor_enterprise_id(self) -> Optional[str]: + """The action's actor's Enterprise Grid organization ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_enterprise_id") + + @property + def actor_team_id(self) -> Optional[str]: + """The action's actor's workspace ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_team_id") + + @property + def actor_user_id(self) -> Optional[str]: + """The action's actor's user ID. + Note that this property is especially useful for handling events in Slack Connect channels. + That being said, it's not guaranteed to have a valid ID for all events due to server-side inconsistency. + """ + return self.get("actor_user_id") @property def channel_id(self) -> Optional[str]: - return self.get("channel_id", None) + """The conversation ID associated with this request.""" + return self.get("channel_id") + + @property + def thread_ts(self) -> Optional[str]: + """The conversation thread's ID associated with this request.""" + return self.get("thread_ts") @property def response_url(self) -> Optional[str]: - return self.get("response_url", None) + """The `response_url` associated with this request.""" + return self.get("response_url") + + @property + def matches(self) -> Optional[Tuple]: + """Returns all the matched parts in message listener's regexp""" + return self.get("matches") + + @property + def function_execution_id(self) -> Optional[str]: + """The `function_execution_id` associated with this request. + Only available for `function_executed` and interactivity events scoped to a custom step. + """ + return self.get("function_execution_id") + + @property + def inputs(self) -> Optional[Dict[str, Any]]: + """The `inputs` associated with this request. + Only available for `function_executed` and interactivity events scoped to a custom step. + """ + return self.get("inputs") + + # -------------------------------- + + @property + def authorize_result(self) -> Optional[AuthorizeResult]: + """The authorize result resolved for this request.""" + return self.get("authorize_result") + + @property + def function_bot_access_token(self) -> Optional[str]: + """The bot token resolved for this function request. + Only available for `function_executed` and interactivity events scoped to a custom step. + """ + return self.get("function_bot_access_token") + + @property + def bot_token(self) -> Optional[str]: + """The bot token resolved for this request.""" + return self.get("bot_token") + + @property + def bot_id(self) -> Optional[str]: + """The bot ID resolved for this request.""" + return self.get("bot_id") + + @property + def bot_user_id(self) -> Optional[str]: + """The bot user ID resolved for this request.""" + return self.get("bot_user_id") + + @property + def user_token(self) -> Optional[str]: + """The user token resolved for this request.""" + return self.get("user_token") + + def set_authorize_result(self, authorize_result: AuthorizeResult): + self["authorize_result"] = authorize_result + if authorize_result.bot_id is not None: + self["bot_id"] = authorize_result.bot_id + if authorize_result.bot_user_id is not None: + self["bot_user_id"] = authorize_result.bot_user_id + if authorize_result.bot_token is not None: + self["bot_token"] = authorize_result.bot_token + if authorize_result.user_id is not None: + self["user_id"] = authorize_result.user_id + if authorize_result.user_token is not None: + self["user_token"] = authorize_result.user_token diff --git a/slack_bolt/context/complete/__init__.py b/slack_bolt/context/complete/__init__.py new file mode 100644 index 000000000..823375ca2 --- /dev/null +++ b/slack_bolt/context/complete/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .complete import Complete + +__all__ = [ + "Complete", +] diff --git a/slack_bolt/context/complete/async_complete.py b/slack_bolt/context/complete/async_complete.py new file mode 100644 index 000000000..bb81c2d4a --- /dev/null +++ b/slack_bolt/context/complete/async_complete.py @@ -0,0 +1,47 @@ +from typing import Any, Dict, Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncComplete: + client: AsyncWebClient + function_execution_id: Optional[str] + _called: bool + + def __init__( + self, + client: AsyncWebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + self._called = False + + async def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> AsyncSlackResponse: + """Signal the successful completion of the custom function. + + Kwargs: + outputs: Json serializable object containing the output values + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("complete is unsupported here as there is no function_execution_id") + + self._called = True + return await self.client.functions_completeSuccess( + function_execution_id=self.function_execution_id, outputs=outputs or {} + ) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called diff --git a/slack_bolt/context/complete/complete.py b/slack_bolt/context/complete/complete.py new file mode 100644 index 000000000..dc9382384 --- /dev/null +++ b/slack_bolt/context/complete/complete.py @@ -0,0 +1,45 @@ +from typing import Any, Dict, Optional + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class Complete: + client: WebClient + function_execution_id: Optional[str] + _called: bool + + def __init__( + self, + client: WebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + self._called = False + + def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse: + """Signal the successful completion of the custom function. + + Kwargs: + outputs: Json serializable object containing the output values + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("complete is unsupported here as there is no function_execution_id") + + self._called = True + return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {}) + + def has_been_called(self) -> bool: + """Check if this complete function has been called. + + Returns: + bool: True if the complete function has been called, False otherwise. + """ + return self._called diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 19fb1cbfb..b101460a5 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -1,27 +1,217 @@ -# pytype: skip-file -from typing import Optional +from typing import Optional, TYPE_CHECKING + +from slack_sdk import WebClient from slack_bolt.context.ack import Ack from slack_bolt.context.base_context import BaseContext +from slack_bolt.context.complete import Complete +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.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.""" + + def to_copyable(self) -> "BoltContext": + new_dict = {} + for prop_name, prop_value in self.items(): + if prop_name in self.copyable_standard_property_names: + # all the standard properties are copiable + new_dict[prop_name] = prop_value + elif prop_name in self.non_copyable_standard_property_names: + # Do nothing with this property (e.g., listener_runner) + continue + else: + try: + copied_value = create_copy(prop_value) + new_dict[prop_name] = copied_value + except TypeError as te: + self.logger.warning( + f"Skipped setting '{prop_name}' to a copied request for lazy listeners " + "due to a deep-copy creation error. Consider passing the value not as part of context object " + f"(error: {te})" + ) + return BoltContext(new_dict) + + # The return type is intentionally string to avoid circular imports + @property + def listener_runner(self) -> "ThreadListenerRunner": + """The properly configured listener_runner that is available for middleware/listeners.""" + return self["listener_runner"] + + @property + def client(self) -> WebClient: + """The `WebClient` instance available for this request. + + @app.event("app_mention") + def handle_events(context): + context.client.chat_postMessage( + channel=context.channel_id, + text="Thanks!", + ) + + # You can access "client" this way too. + @app.event("app_mention") + def handle_events(client, context): + client.chat_postMessage( + channel=context.channel_id, + text="Thanks!", + ) + + Returns: + `WebClient` instance + """ + if "client" not in self: + self["client"] = WebClient(token=None) + return self["client"] + @property def ack(self) -> Ack: + """`ack()` function for this request. + + @app.action("button") + def handle_button_clicks(context): + context.ack() + + # You can access "ack" this way too. + @app.action("button") + def handle_button_clicks(ack): + ack() + + Returns: + Callable `ack()` function + """ if "ack" not in self: self["ack"] = Ack() return self["ack"] @property def say(self) -> Say: + """`say()` function for this request. + + @app.action("button") + def handle_button_clicks(context): + context.ack() + context.say("Hi!") + + # You can access "ack" this way too. + @app.action("button") + def handle_button_clicks(ack, say): + ack() + say("Hi!") + + Returns: + Callable `say()` function + """ if "say" not in self: self["say"] = Say(client=self.client, channel=self.channel_id) return self["say"] @property def respond(self) -> Optional[Respond]: + """`respond()` function for this request. + + @app.action("button") + def handle_button_clicks(context): + context.ack() + context.respond("Hi!") + + # You can access "ack" this way too. + @app.action("button") + def handle_button_clicks(ack, respond): + ack() + respond("Hi!") + + Returns: + Callable `respond()` function + """ if "respond" not in self: - self["respond"] = Respond(response_url=self.response_url) + self["respond"] = Respond( + response_url=self.response_url, + proxy=self.client.proxy, + ssl=self.client.ssl, + ) return self["respond"] + + @property + def complete(self) -> Complete: + """`complete()` function for this request. Once a custom function's state is set to complete, + any outputs the function returns will be passed along to the next step of its housing workflow, + or complete the workflow if the function is the last step in a workflow. Additionally, + any interactivity handlers associated to a function invocation will no longer be invocable. + + @app.function("reverse") + def handle_button_clicks(ack, complete): + ack() + complete(outputs={"stringReverse":"olleh"}) + + @app.function("reverse") + def handle_button_clicks(context): + context.ack() + context.complete(outputs={"stringReverse":"olleh"}) + + Returns: + Callable `complete()` function + """ + if "complete" not in self: + self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id) + return self["complete"] + + @property + def fail(self) -> Fail: + """`fail()` function for this request. Once a custom function's state is set to error, + its housing workflow will be interrupted and any provided error message will be passed + on to the end user through SlackBot. Additionally, any interactivity handlers associated + to a function invocation will no longer be invocable. + + @app.function("reverse") + def handle_button_clicks(ack, fail): + ack() + fail(error="something went wrong") + + @app.function("reverse") + def handle_button_clicks(context): + context.ack() + context.fail(error="something went wrong") + + Returns: + Callable `fail()` function + """ + if "fail" not in self: + self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id) + return self["fail"] + + @property + def set_title(self) -> Optional[SetTitle]: + return self.get("set_title") + + @property + def set_status(self) -> Optional[SetStatus]: + return self.get("set_status") + + @property + def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]: + return self.get("set_suggested_prompts") + + @property + 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/fail/__init__.py b/slack_bolt/context/fail/__init__.py new file mode 100644 index 000000000..b306f8452 --- /dev/null +++ b/slack_bolt/context/fail/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .fail import Fail + +__all__ = [ + "Fail", +] diff --git a/slack_bolt/context/fail/async_fail.py b/slack_bolt/context/fail/async_fail.py new file mode 100644 index 000000000..da01067ba --- /dev/null +++ b/slack_bolt/context/fail/async_fail.py @@ -0,0 +1,45 @@ +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncFail: + client: AsyncWebClient + function_execution_id: Optional[str] + _called: bool + + def __init__( + self, + client: AsyncWebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + self._called = False + + async def __call__(self, error: str) -> AsyncSlackResponse: + """Signal that the custom function failed to complete. + + Kwargs: + error: Error message to return to slack + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("fail is unsupported here as there is no function_execution_id") + + self._called = True + return await self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called diff --git a/slack_bolt/context/fail/fail.py b/slack_bolt/context/fail/fail.py new file mode 100644 index 000000000..9b04f6118 --- /dev/null +++ b/slack_bolt/context/fail/fail.py @@ -0,0 +1,45 @@ +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class Fail: + client: WebClient + function_execution_id: Optional[str] + _called: bool + + def __init__( + self, + client: WebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + self._called = False + + def __call__(self, error: str) -> SlackResponse: + """Signal that the custom function failed to complete. + + Kwargs: + error: Error message to return to slack + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("fail is unsupported here as there is no function_execution_id") + + self._called = True + return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) + + def has_been_called(self) -> bool: + """Check if this fail function has been called. + + Returns: + bool: True if the fail function has been called, False otherwise. + """ + return self._called diff --git a/slack_bolt/context/get_thread_context/__init__.py b/slack_bolt/context/get_thread_context/__init__.py new file mode 100644 index 000000000..dd99b1b20 --- /dev/null +++ b/slack_bolt/context/get_thread_context/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .get_thread_context import GetThreadContext + +__all__ = [ + "GetThreadContext", +] 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 new file mode 100644 index 000000000..03f7c6076 --- /dev/null +++ b/slack_bolt/context/get_thread_context/async_get_thread_context.py @@ -0,0 +1,44 @@ +from typing import Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore + + +class AsyncGetThreadContext: + thread_context_store: AsyncAssistantThreadContextStore + payload: dict + channel_id: str + thread_ts: str + + _thread_context: Optional[AssistantThreadContext] + thread_context_loaded: bool + + def __init__( + self, + thread_context_store: AsyncAssistantThreadContextStore, + channel_id: str, + thread_ts: str, + payload: dict, + ): + self.thread_context_store = thread_context_store + self.payload = payload + self.channel_id = channel_id + self.thread_ts = thread_ts + self._thread_context: Optional[AssistantThreadContext] = None + self.thread_context_loaded = False + + async def __call__(self) -> Optional[AssistantThreadContext]: + if self.thread_context_loaded is True: + return self._thread_context + + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: + # assistant_thread_started + 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: + # message event + self._thread_context = await self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts) + + return self._thread_context diff --git a/slack_bolt/context/get_thread_context/get_thread_context.py b/slack_bolt/context/get_thread_context/get_thread_context.py new file mode 100644 index 000000000..b9c9751e1 --- /dev/null +++ b/slack_bolt/context/get_thread_context/get_thread_context.py @@ -0,0 +1,44 @@ +from typing import Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + + +class GetThreadContext: + thread_context_store: AssistantThreadContextStore + payload: dict + channel_id: str + thread_ts: str + + _thread_context: Optional[AssistantThreadContext] + thread_context_loaded: bool + + def __init__( + self, + thread_context_store: AssistantThreadContextStore, + channel_id: str, + thread_ts: str, + payload: dict, + ): + self.thread_context_store = thread_context_store + self.payload = payload + self.channel_id = channel_id + self.thread_ts = thread_ts + self._thread_context: Optional[AssistantThreadContext] = None + self.thread_context_loaded = False + + def __call__(self) -> Optional[AssistantThreadContext]: + if self.thread_context_loaded is True: + return self._thread_context + + thread = self.payload.get("assistant_thread") + if isinstance(thread, dict) and thread.get("context", {}).get("channel_id") is not None: + # assistant_thread_started + 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: + # message event + self._thread_context = self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts) + + return self._thread_context diff --git a/slack_bolt/context/respond/__init__.py b/slack_bolt/context/respond/__init__.py index 2b240ebab..72670819f 100644 --- a/slack_bolt/context/respond/__init__.py +++ b/slack_bolt/context/respond/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here from .respond import Respond + +__all__ = [ + "Respond", +] diff --git a/slack_bolt/context/respond/async_respond.py b/slack_bolt/context/respond/async_respond.py index 5a87075e4..c91324406 100644 --- a/slack_bolt/context/respond/async_respond.py +++ b/slack_bolt/context/respond/async_respond.py @@ -1,4 +1,5 @@ -from typing import Optional, List, Union +from typing import Optional, Union, Sequence, Dict, Any +from ssl import SSLContext from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -9,30 +10,52 @@ class AsyncRespond: response_url: Optional[str] + proxy: Optional[str] + ssl: Optional[SSLContext] - def __init__(self, *, response_url: Optional[str]): - self.response_url: Optional[str] = response_url + def __init__( + self, + *, + response_url: Optional[str], + proxy: Optional[str] = None, + ssl: Optional[SSLContext] = None, + ): + self.response_url = response_url + self.proxy = proxy + self.ssl = ssl async def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + thread_ts: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> WebhookResponse: if self.response_url is not None: - client = AsyncWebhookClient(self.response_url) + client = AsyncWebhookClient( + url=self.response_url, + proxy=self.proxy, + ssl=self.ssl, + ) text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): message = _build_message( - text=text, + text=text, # type: ignore[arg-type] blocks=blocks, attachments=attachments, response_type=response_type, replace_original=replace_original, delete_original=delete_original, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + thread_ts=thread_ts, + metadata=metadata, ) return await client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/slack_bolt/context/respond/internals.py b/slack_bolt/context/respond/internals.py index d2e51d1b2..32bcac5de 100644 --- a/slack_bolt/context/respond/internals.py +++ b/slack_bolt/context/respond/internals.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Dict, Union, Any +from typing import Optional, Dict, Union, Any, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -8,13 +8,17 @@ def _build_message( text: str = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + thread_ts: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: - message = {"text": text} + message: Dict[str, Any] = {"text": text} if blocks is not None and len(blocks) > 0: message["blocks"] = convert_to_dict_list(blocks) if attachments is not None and len(attachments) > 0: @@ -25,4 +29,12 @@ def _build_message( message["replace_original"] = replace_original if delete_original is not None: message["delete_original"] = delete_original + if unfurl_links is not None: + message["unfurl_links"] = unfurl_links + if unfurl_media is not None: + message["unfurl_media"] = unfurl_media + if thread_ts is not None: + message["thread_ts"] = thread_ts + if metadata is not None: + message["metadata"] = metadata return message diff --git a/slack_bolt/context/respond/respond.py b/slack_bolt/context/respond/respond.py index eb085a4ee..0f16f6851 100644 --- a/slack_bolt/context/respond/respond.py +++ b/slack_bolt/context/respond/respond.py @@ -1,4 +1,5 @@ -from typing import Optional, List, Union +from typing import Optional, Union, Sequence, Any, Dict +from ssl import SSLContext from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block @@ -9,21 +10,39 @@ class Respond: response_url: Optional[str] + proxy: Optional[str] + ssl: Optional[SSLContext] - def __init__(self, *, response_url: Optional[str]): - self.response_url: Optional[str] = response_url + def __init__( + self, + *, + response_url: Optional[str], + proxy: Optional[str] = None, + ssl: Optional[SSLContext] = None, + ): + self.response_url = response_url + self.proxy = proxy + self.ssl = ssl def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, response_type: Optional[str] = None, replace_original: Optional[bool] = None, delete_original: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + thread_ts: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> WebhookResponse: if self.response_url is not None: - client = WebhookClient(self.response_url) + client = WebhookClient( + url=self.response_url, + proxy=self.proxy, + ssl=self.ssl, + ) text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): text = text_or_whole_response @@ -34,14 +53,16 @@ def __call__( response_type=response_type, replace_original=replace_original, delete_original=delete_original, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + thread_ts=thread_ts, + metadata=metadata, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): message = _build_message(**text_or_whole_response) return client.send_dict(message) else: - raise ValueError( - f"The arg is unexpected type ({type(text_or_whole_response)})" - ) + raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})") else: raise ValueError("respond is unsupported here as there is no response_url") diff --git a/slack_bolt/context/save_thread_context/__init__.py b/slack_bolt/context/save_thread_context/__init__.py new file mode 100644 index 000000000..4980e0830 --- /dev/null +++ b/slack_bolt/context/save_thread_context/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .save_thread_context import SaveThreadContext + +__all__ = [ + "SaveThreadContext", +] diff --git a/slack_bolt/context/save_thread_context/async_save_thread_context.py b/slack_bolt/context/save_thread_context/async_save_thread_context.py new file mode 100644 index 000000000..ff79f5f64 --- /dev/null +++ b/slack_bolt/context/save_thread_context/async_save_thread_context.py @@ -0,0 +1,26 @@ +from typing import Dict + +from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore + + +class AsyncSaveThreadContext: + thread_context_store: AsyncAssistantThreadContextStore + channel_id: str + thread_ts: str + + def __init__( + self, + thread_context_store: AsyncAssistantThreadContextStore, + channel_id: str, + thread_ts: str, + ): + self.thread_context_store = thread_context_store + self.channel_id = channel_id + self.thread_ts = thread_ts + + async def __call__(self, new_context: Dict[str, str]) -> None: + await self.thread_context_store.save( + channel_id=self.channel_id, + thread_ts=self.thread_ts, + context=new_context, + ) diff --git a/slack_bolt/context/save_thread_context/save_thread_context.py b/slack_bolt/context/save_thread_context/save_thread_context.py new file mode 100644 index 000000000..4d0a13dfd --- /dev/null +++ b/slack_bolt/context/save_thread_context/save_thread_context.py @@ -0,0 +1,26 @@ +from typing import Dict + +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + + +class SaveThreadContext: + thread_context_store: AssistantThreadContextStore + channel_id: str + thread_ts: str + + def __init__( + self, + thread_context_store: AssistantThreadContextStore, + channel_id: str, + thread_ts: str, + ): + self.thread_context_store = thread_context_store + self.channel_id = channel_id + self.thread_ts = thread_ts + + def __call__(self, new_context: Dict[str, str]) -> None: + self.thread_context_store.save( + channel_id=self.channel_id, + thread_ts=self.thread_ts, + context=new_context, + ) diff --git a/slack_bolt/context/say/__init__.py b/slack_bolt/context/say/__init__.py index 828ad8877..84ecd5be4 100644 --- a/slack_bolt/context/say/__init__.py +++ b/slack_bolt/context/say/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here from .say import Say + +__all__ = [ + "Say", +] diff --git a/slack_bolt/context/say/async_say.py b/slack_bolt/context/say/async_say.py index 3a7cb9b59..c492e5d77 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -1,47 +1,90 @@ -from typing import Optional, List, Union, Dict +from typing import Awaitable, Callable, Dict, Optional, Sequence, Union -from slack_bolt.context.say.internals import _can_say from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.metadata import Metadata from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse +from slack_bolt.context.say.internals import _can_say +from slack_bolt.util.utils import create_copy + class AsyncSay: client: Optional[AsyncWebClient] channel: Optional[str] + thread_ts: Optional[str] + build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]] def __init__( - self, client: Optional[AsyncWebClient], channel: Optional[str], + self, + client: Optional[AsyncWebClient], + channel: Optional[str], + thread_ts: Optional[str] = None, + build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]] = None, ): self.client = client self.channel = channel + self.thread_ts = thread_ts + self.build_metadata = build_metadata async def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[Dict, Block]]] = None, - attachments: Optional[List[Union[Dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[Dict, Block]]] = None, + attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, + as_user: Optional[bool] = None, + thread_ts: Optional[str] = None, + reply_broadcast: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: Optional[str] = None, + markdown_text: Optional[str] = None, + mrkdwn: Optional[bool] = None, + link_names: Optional[bool] = None, + parse: Optional[str] = None, # none, full + metadata: Optional[Union[Dict, Metadata]] = None, + **kwargs, ) -> AsyncSlackResponse: if _can_say(self, channel): + if metadata is None and self.build_metadata is not None: + metadata = await self.build_metadata() text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): text = text_or_whole_response - return await self.client.chat_postMessage( - channel=channel or self.channel, + return await self.client.chat_postMessage( # type: ignore[union-attr] + channel=channel or self.channel, # type: ignore[arg-type] text=text, blocks=blocks, attachments=attachments, + as_user=as_user, + thread_ts=thread_ts or self.thread_ts, + reply_broadcast=reply_broadcast, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, + markdown_text=markdown_text, + mrkdwn=mrkdwn, + link_names=link_names, + parse=parse, + metadata=metadata, + **kwargs, ) elif isinstance(text_or_whole_response, dict): - message: dict = text_or_whole_response + message: dict = create_copy(text_or_whole_response) if "channel" not in message: message["channel"] = channel or self.channel - return await self.client.chat_postMessage(**message) + if "thread_ts" not in message: + message["thread_ts"] = thread_ts or self.thread_ts + if "metadata" not in message: + message["metadata"] = metadata + return await self.client.chat_postMessage(**message) # type: ignore[union-attr] else: - raise ValueError( - f"The arg is unexpected type ({type(text_or_whole_response)})" - ) + raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})") else: raise ValueError("say without channel_id here is unsupported") diff --git a/slack_bolt/context/say/internals.py b/slack_bolt/context/say/internals.py index 2796f767a..233f54852 100644 --- a/slack_bolt/context/say/internals.py +++ b/slack_bolt/context/say/internals.py @@ -2,8 +2,4 @@ def _can_say(self: Any, channel: Optional[str]) -> bool: - return ( - hasattr(self, "client") - and self.client is not None - and (channel or self.channel) is not None - ) + return hasattr(self, "client") and self.client is not None and (channel or self.channel) is not None diff --git a/slack_bolt/context/say/say.py b/slack_bolt/context/say/say.py index 2a41078c6..a6e5904e3 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -1,48 +1,94 @@ -from typing import Optional, List, Union, Dict +from typing import Callable, Dict, Optional, Sequence, Union from slack_sdk import WebClient from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block +from slack_sdk.models.metadata import Metadata from slack_sdk.web import SlackResponse from slack_bolt.context.say.internals import _can_say +from slack_bolt.util.utils import create_copy class Say: client: Optional[WebClient] channel: Optional[str] + thread_ts: Optional[str] + metadata: Optional[Union[Dict, Metadata]] + build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]] def __init__( - self, client: Optional[WebClient], channel: Optional[str], + self, + client: Optional[WebClient], + channel: Optional[str], + thread_ts: Optional[str] = None, + metadata: Optional[Union[Dict, Metadata]] = None, + build_metadata: Optional[Callable[[], Optional[Union[Dict, Metadata]]]] = None, ): self.client = client self.channel = channel + self.thread_ts = thread_ts + self.metadata = metadata + self.build_metadata = build_metadata def __call__( self, text: Union[str, dict] = "", - blocks: Optional[List[Union[Dict, Block]]] = None, - attachments: Optional[List[Union[Dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[Dict, Block]]] = None, + attachments: Optional[Sequence[Union[Dict, Attachment]]] = None, channel: Optional[str] = None, + as_user: Optional[bool] = None, + thread_ts: Optional[str] = None, + reply_broadcast: Optional[bool] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, + icon_emoji: Optional[str] = None, + icon_url: Optional[str] = None, + username: Optional[str] = None, + markdown_text: Optional[str] = None, + mrkdwn: Optional[bool] = None, + link_names: Optional[bool] = None, + parse: Optional[str] = None, # none, full + metadata: Optional[Union[Dict, Metadata]] = None, + **kwargs, ) -> SlackResponse: if _can_say(self, channel): text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): text = text_or_whole_response - return self.client.chat_postMessage( - channel=channel or self.channel, + if metadata is None: + metadata = self.build_metadata() if self.build_metadata is not None else self.metadata + return self.client.chat_postMessage( # type: ignore[union-attr] + channel=channel or self.channel, # type: ignore[arg-type] text=text, blocks=blocks, attachments=attachments, + as_user=as_user, + thread_ts=thread_ts or self.thread_ts, + reply_broadcast=reply_broadcast, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, + icon_emoji=icon_emoji, + icon_url=icon_url, + username=username, + markdown_text=markdown_text, + mrkdwn=mrkdwn, + link_names=link_names, + parse=parse, + metadata=metadata, + **kwargs, ) elif isinstance(text_or_whole_response, dict): - message: dict = text_or_whole_response + message: dict = create_copy(text_or_whole_response) if "channel" not in message: message["channel"] = channel or self.channel - return self.client.chat_postMessage(**message) + if "thread_ts" not in message: + message["thread_ts"] = thread_ts or self.thread_ts + if "metadata" not in message: + metadata = self.build_metadata() if self.build_metadata is not None else self.metadata + message["metadata"] = metadata + return self.client.chat_postMessage(**message) # type: ignore[union-attr] else: - raise ValueError( - f"The arg is unexpected type ({type(text_or_whole_response)})" - ) + raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})") else: raise ValueError("say without channel_id here is unsupported") 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/__init__.py b/slack_bolt/context/set_status/__init__.py new file mode 100644 index 000000000..c12f9658b --- /dev/null +++ b/slack_bolt/context/set_status/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .set_status import SetStatus + +__all__ = [ + "SetStatus", +] diff --git a/slack_bolt/context/set_status/async_set_status.py b/slack_bolt/context/set_status/async_set_status.py new file mode 100644 index 000000000..f10cc195c --- /dev/null +++ b/slack_bolt/context/set_status/async_set_status.py @@ -0,0 +1,40 @@ +from typing import List, Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncSetStatus: + client: AsyncWebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: AsyncWebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + 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( + channel_id=self.channel_id, + 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 new file mode 100644 index 000000000..055a5cab7 --- /dev/null +++ b/slack_bolt/context/set_status/set_status.py @@ -0,0 +1,40 @@ +from typing import List, Optional + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class SetStatus: + client: WebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: WebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + 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( + channel_id=self.channel_id, + 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/__init__.py b/slack_bolt/context/set_suggested_prompts/__init__.py new file mode 100644 index 000000000..e5efd26c7 --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .set_suggested_prompts import SetSuggestedPrompts + +__all__ = [ + "SetSuggestedPrompts", +] 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 new file mode 100644 index 000000000..68b41858b --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py @@ -0,0 +1,40 @@ +from typing import Dict, List, Optional, Sequence, Union + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncSetSuggestedPrompts: + client: AsyncWebClient + channel_id: str + thread_ts: Optional[str] + + def __init__( + self, + client: AsyncWebClient, + channel_id: str, + thread_ts: Optional[str] = None, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + 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: + 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=self.channel_id, + 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 new file mode 100644 index 000000000..349481df4 --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -0,0 +1,40 @@ +from typing import Dict, List, Optional, Sequence, Union + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class SetSuggestedPrompts: + client: WebClient + channel_id: str + thread_ts: Optional[str] + + def __init__( + self, + client: WebClient, + channel_id: str, + thread_ts: Optional[str] = None, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + 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: + if isinstance(prompt, str): + prompts_arg.append({"title": prompt, "message": prompt}) + else: + prompts_arg.append(prompt) + + return self.client.assistant_threads_setSuggestedPrompts( + channel_id=self.channel_id, + 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_title/__init__.py b/slack_bolt/context/set_title/__init__.py new file mode 100644 index 000000000..e799e88ae --- /dev/null +++ b/slack_bolt/context/set_title/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .set_title import SetTitle + +__all__ = [ + "SetTitle", +] diff --git a/slack_bolt/context/set_title/async_set_title.py b/slack_bolt/context/set_title/async_set_title.py new file mode 100644 index 000000000..ea6bfc98a --- /dev/null +++ b/slack_bolt/context/set_title/async_set_title.py @@ -0,0 +1,25 @@ +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncSetTitle: + client: AsyncWebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: AsyncWebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + async def __call__(self, title: str) -> AsyncSlackResponse: + return await self.client.assistant_threads_setTitle( + title=title, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + ) diff --git a/slack_bolt/context/set_title/set_title.py b/slack_bolt/context/set_title/set_title.py new file mode 100644 index 000000000..5670c6b73 --- /dev/null +++ b/slack_bolt/context/set_title/set_title.py @@ -0,0 +1,25 @@ +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class SetTitle: + client: WebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: WebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + def __call__(self, title: str) -> SlackResponse: + return self.client.assistant_threads_setTitle( + title=title, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + ) diff --git a/slack_bolt/error/__init__.py b/slack_bolt/error/__init__.py index 3ad06e5c6..19716cd74 100644 --- a/slack_bolt/error/__init__.py +++ b/slack_bolt/error/__init__.py @@ -1,2 +1,29 @@ +"""Bolt specific error types.""" + +from typing import Optional, Union + + class BoltError(Exception): """General class in a Bolt app""" + + +class BoltUnhandledRequestError(BoltError): + request: "BoltRequest" # type: ignore[name-defined] + body: dict + current_response: Optional["BoltResponse"] # type: ignore[name-defined] + last_global_middleware_name: Optional[str] + + def __init__( + self, + *, + request: Union["BoltRequest", "AsyncBoltRequest"], # type: ignore[name-defined] + current_response: Optional["BoltResponse"], # type: ignore[name-defined] + last_global_middleware_name: Optional[str] = None, + ): + self.request = request + self.body = request.body if request is not None else {} + self.current_response = current_response + self.last_global_middleware_name = last_global_middleware_name + + def __str__(self) -> str: + return "unhandled request error" diff --git a/slack_bolt/kwargs_injection/__init__.py b/slack_bolt/kwargs_injection/__init__.py index e63b7e87d..36f44ebea 100644 --- a/slack_bolt/kwargs_injection/__init__.py +++ b/slack_bolt/kwargs_injection/__init__.py @@ -1,3 +1,14 @@ +"""For middleware/listener arguments, Bolt does flexible data injection in accordance with their names. + +To learn the available arguments, check `slack_bolt.kwargs_injection.args`'s API document. +For steps from apps, checking `slack_bolt.workflows.step.utilities` as well should be helpful. +""" + # Don't add async module imports here from .args import Args from .utils import build_required_kwargs + +__all__ = [ + "Args", + "build_required_kwargs", +] diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 7215be1ae..f2b4099d6 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -1,30 +1,115 @@ -# pytype: skip-file import logging from logging import Logger -from typing import Callable, Dict, Any +from typing import Callable, Dict, Any, Optional from slack_bolt.context import BoltContext from slack_bolt.context.ack import Ack +from slack_bolt.context.complete import Complete +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.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.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk import WebClient class Args: + """All the arguments in this class are available in any middleware / listeners. + You can inject the named variables in the argument list in arbitrary order. + + @app.action("link_button") + def handle_buttons(ack, respond, logger, context, body, client): + logger.info(f"request body: {body}") + ack() + if context.channel_id is not None: + respond("Hi!") + client.views_open( + trigger_id=body["trigger_id"], + view={ ... } + ) + + Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class. + + @app.action("link_button") + def handle_buttons(args): + args.logger.info(f"request body: {args.body}") + args.ack() + if args.context.channel_id is not None: + args.respond("Hi!") + args.client.views_open( + trigger_id=args.body["trigger_id"], + view={ ... } + ) + + """ + client: WebClient + """`slack_sdk.web.WebClient` instance with a valid token""" logger: Logger + """Logger instance""" req: BoltRequest + """Incoming request from Slack""" resp: BoltResponse + """Response representation""" request: BoltRequest + """Incoming request from Slack""" response: BoltResponse + """Response representation""" context: BoltContext + """Context data associated with the incoming request""" + body: Dict[str, Any] + """Parsed request body data""" + # payload payload: Dict[str, Any] + """The unwrapped core data in the request body""" + options: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.options` listener""" + shortcut: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.shortcut` listener""" + action: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.action` listener""" + view: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.view` listener""" + command: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.command` listener""" + event: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.event` listener""" + message: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.message` listener""" + # utilities ack: Ack + """`ack()` utility function, which returns acknowledgement to the Slack servers""" say: Say + """`say()` utility function, which calls `chat.postMessage` API with the associated channel ID""" respond: Respond + """`respond()` utility function, which utilizes the associated `response_url`""" + complete: Complete + """`complete()` utility function, signals a successful completion of the custom function""" + fail: Fail + """`fail()` utility function, signal that the custom function failed to complete""" + set_status: Optional[SetStatus] + """`set_status()` utility function for AI Agents & Assistants""" + set_title: Optional[SetTitle] + """`set_title()` utility function for AI Agents & Assistants""" + set_suggested_prompts: Optional[SetSuggestedPrompts] + """`set_suggested_prompts()` utility function for AI Agents & Assistants""" + get_thread_context: Optional[GetThreadContext] + """`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""" + next_: Callable[[], None] + """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions""" def __init__( self, @@ -34,21 +119,60 @@ def __init__( req: BoltRequest, resp: BoltResponse, context: BoltContext, + body: Dict[str, Any], payload: Dict[str, Any], + options: Optional[Dict[str, Any]] = None, + shortcut: Optional[Dict[str, Any]] = None, + action: Optional[Dict[str, Any]] = None, + view: Optional[Dict[str, Any]] = None, + command: Optional[Dict[str, Any]] = None, + event: Optional[Dict[str, Any]] = None, + message: Optional[Dict[str, Any]] = None, ack: Ack, say: Say, respond: Respond, + complete: Complete, + fail: Fail, + set_status: Optional[SetStatus] = None, + set_title: Optional[SetTitle] = None, + 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 next: Callable[[], None], - **kwargs # noqa + **kwargs, # noqa ): self.logger: logging.Logger = logger self.client: WebClient = client self.request = self.req = req self.response = self.resp = resp self.context: BoltContext = context + + self.body: Dict[str, Any] = body self.payload: Dict[str, Any] = payload - self.body: Dict[str, Any] = payload + self.options: Optional[Dict[str, Any]] = options + self.shortcut: Optional[Dict[str, Any]] = shortcut + self.action: Optional[Dict[str, Any]] = action + self.view: Optional[Dict[str, Any]] = view + self.command: Optional[Dict[str, Any]] = command + self.event: Optional[Dict[str, Any]] = event + self.message: Optional[Dict[str, Any]] = message + self.ack: Ack = ack self.say: Say = say self.respond: Respond = respond + self.complete: Complete = complete + self.fail: Fail = fail + + self.set_status = set_status + self.set_title = set_title + 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 diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 68e5565ad..2217cfe9f 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -1,29 +1,114 @@ -# pytype: skip-file from logging import Logger -from typing import Callable, Awaitable, Dict, Any +from typing import Callable, Awaitable, Dict, Any, Optional from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.complete.async_complete import AsyncComplete +from slack_bolt.context.fail.async_fail import AsyncFail from slack_bolt.context.respond.async_respond import AsyncRespond +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.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_sdk.web.async_client import AsyncWebClient class AsyncArgs: + """All the arguments in this class are available in any middleware / listeners. + You can inject the named variables in the argument list in arbitrary order. + + @app.action("link_button") + async def handle_buttons(ack, respond, logger, context, body, client): + logger.info(f"request body: {body}") + await ack() + if context.channel_id is not None: + await respond("Hi!") + await client.views_open( + trigger_id=body["trigger_id"], + view={ ... } + ) + + Alternatively, you can include a parameter named `args` and it will be injected with an instance of this class. + + @app.action("link_button") + async def handle_buttons(args): + args.logger.info(f"request body: {args.body}") + await args.ack() + if args.context.channel_id is not None: + await args.respond("Hi!") + await args.client.views_open( + trigger_id=args.body["trigger_id"], + view={ ... } + ) + + """ + logger: Logger + """Logger instance""" client: AsyncWebClient + """`slack_sdk.web.async_client.AsyncWebClient` instance with a valid token""" req: AsyncBoltRequest + """Incoming request from Slack""" resp: BoltResponse + """Response representation""" request: AsyncBoltRequest + """Incoming request from Slack""" response: BoltResponse + """Response representation""" context: AsyncBoltContext + """Context data associated with the incoming request""" + body: Dict[str, Any] + """Parsed request body data""" + # payload payload: Dict[str, Any] + """The unwrapped core data in the request body""" + options: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.options` listener""" + shortcut: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.shortcut` listener""" + action: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.action` listener""" + view: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.view` listener""" + command: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.command` listener""" + event: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.event` listener""" + message: Optional[Dict[str, Any]] # payload alias + """An alias for payload in an `@app.message` listener""" + # utilities ack: AsyncAck + """`ack()` utility function, which returns acknowledgement to the Slack servers""" say: AsyncSay + """`say()` utility function, which calls chat.postMessage API with the associated channel ID""" respond: AsyncRespond + """`respond()` utility function, which utilizes the associated `response_url`""" + complete: AsyncComplete + """`complete()` utility function, signals a successful completion of the custom function""" + fail: AsyncFail + """`fail()` utility function, signal that the custom function failed to complete""" + set_status: Optional[AsyncSetStatus] + """`set_status()` utility function for AI Agents & Assistants""" + set_title: Optional[AsyncSetTitle] + """`set_title()` utility function for AI Agents & Assistants""" + set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] + """`set_suggested_prompts()` utility function for AI Agents & Assistants""" + get_thread_context: Optional[AsyncGetThreadContext] + """`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""" + next_: Callable[[], Awaitable[None]] + """An alias of `next()` for avoiding the Python built-in method overrides in middleware functions""" def __init__( self, @@ -33,21 +118,57 @@ def __init__( req: AsyncBoltRequest, resp: BoltResponse, context: AsyncBoltContext, + body: Dict[str, Any], payload: Dict[str, Any], + options: Optional[Dict[str, Any]] = None, + shortcut: Optional[Dict[str, Any]] = None, + action: Optional[Dict[str, Any]] = None, + view: Optional[Dict[str, Any]] = None, + command: Optional[Dict[str, Any]] = None, + event: Optional[Dict[str, Any]] = None, + message: Optional[Dict[str, Any]] = None, ack: AsyncAck, say: AsyncSay, respond: AsyncRespond, + complete: AsyncComplete, + fail: AsyncFail, + set_status: Optional[AsyncSetStatus] = None, + set_title: Optional[AsyncSetTitle] = None, + 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 + **kwargs, # noqa ): self.logger: Logger = logger self.client: AsyncWebClient = client self.request = self.req = req self.response = self.resp = resp self.context: AsyncBoltContext = context + + self.body: Dict[str, Any] = body self.payload: Dict[str, Any] = payload - self.body: Dict[str, Any] = payload + self.options: Optional[Dict[str, Any]] = options + self.shortcut: Optional[Dict[str, Any]] = shortcut + self.action: Optional[Dict[str, Any]] = action + self.view: Optional[Dict[str, Any]] = view + self.command: Optional[Dict[str, Any]] = command + self.event: Optional[Dict[str, Any]] = event + self.message: Optional[Dict[str, Any]] = message + self.ack: AsyncAck = ack self.say: AsyncSay = say self.respond: AsyncRespond = respond + self.complete: AsyncComplete = complete + self.fail: AsyncFail = fail + + self.set_status = set_status + self.set_title = set_title + 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 diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index 5e8298704..246fd10c9 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -1,21 +1,35 @@ -# pytype: skip-file +import inspect import logging -from typing import Callable, Dict, Optional, List, Any +from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_args import AsyncArgs +from slack_bolt.request.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, + to_step, +) +from ..logger.messages import warning_skip_uncommon_arg_name def build_async_required_kwargs( *, logger: logging.Logger, - required_arg_names: List[str], + required_arg_names: MutableSequence[str], request: AsyncBoltRequest, response: Optional[BoltResponse], - next_func: Callable[[], None] = None, + next_func: Optional[Callable[[], None]] = None, + this_func: Optional[Callable] = None, + 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, @@ -23,27 +37,77 @@ def build_async_required_kwargs( "resp": response, "response": response, "context": request.context, - "payload": request.payload, - "body": request.payload, + "body": request.body, + # payload + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), + "step": to_step(request.body), + # utilities "ack": request.context.ack, "say": request.context.say, "respond": request.context.respond, + "complete": request.context.complete, + "fail": request.context.fail, + "set_status": request.context.set_status, + "set_title": request.context.set_title, + "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 + # error handler + "error": error, # Exception } - kwargs: Dict[str, Any] = { - k: v for k, v in all_available_args.items() if k in required_arg_names - } + if not next_keys_required: + all_available_args.pop("next") + all_available_args.pop("next_") + + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or all_available_args["step"] + or request.body + ) + for k, v in request.context.items(): + if k not in all_available_args: + all_available_args[k] = v + + if len(required_arg_names) > 0: + # To support instance/class methods in a class for listeners/middleware, + # check if the first argument is either self or cls + first_arg_name = required_arg_names[0] + if first_arg_name in {"self", "cls"}: + required_arg_names.pop(0) + elif first_arg_name not in all_available_args.keys() and first_arg_name != "args": + if this_func is None: + logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) + required_arg_names.pop(0) + elif inspect.ismethod(this_func): + # We are sure that we should skip manipulating this arg + required_arg_names.pop(0) + + kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in required_arg_names} found_arg_names = kwargs.keys() for name in required_arg_names: if name == "args": if isinstance(request, AsyncBoltRequest): kwargs[name] = AsyncArgs(**all_available_args) else: - logger.warning( - f"Unknown Request object type detected ({type(request)})" - ) + logger.warning(f"Unknown Request object type detected ({type(request)})") - if name not in found_arg_names: + elif name not in found_arg_names: logger.warning(f"{name} is not a valid argument") kwargs[name] = None return kwargs diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 40f754e0d..218fbeb6e 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,21 +1,35 @@ -# pytype: skip-file +import inspect import logging -from typing import Callable, Dict, Optional, List, Any +from typing import Callable, Dict, MutableSequence, Optional, Any from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from .args import Args +from slack_bolt.request.payload_utils import ( + to_options, + to_shortcut, + to_action, + to_view, + to_command, + to_event, + to_message, + to_step, +) +from ..logger.messages import warning_skip_uncommon_arg_name def build_required_kwargs( *, logger: logging.Logger, - required_arg_names: List[str], + required_arg_names: MutableSequence[str], request: BoltRequest, response: Optional[BoltResponse], - next_func: Callable[[], None] = None, + next_func: Optional[Callable[[], None]] = None, + this_func: Optional[Callable] = None, + 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, @@ -23,27 +37,76 @@ def build_required_kwargs( "resp": response, "response": response, "context": request.context, - "payload": request.payload, - "body": request.payload, + # payload + "body": request.body, + "options": to_options(request.body), + "shortcut": to_shortcut(request.body), + "action": to_action(request.body), + "view": to_view(request.body), + "command": to_command(request.body), + "event": to_event(request.body), + "message": to_message(request.body), + "step": to_step(request.body), + # utilities "ack": request.context.ack, "say": request.context.say, "respond": request.context.respond, + "complete": request.context.complete, + "fail": request.context.fail, + "set_status": request.context.set_status, + "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 + # error handler + "error": error, # Exception } - kwargs: Dict[str, Any] = { - k: v for k, v in all_available_args.items() if k in required_arg_names - } + if not next_keys_required: + all_available_args.pop("next") + all_available_args.pop("next_") + + all_available_args["payload"] = ( + all_available_args["options"] + or all_available_args["shortcut"] + or all_available_args["action"] + or all_available_args["view"] + or all_available_args["command"] + or all_available_args["event"] + or all_available_args["message"] + or all_available_args["step"] + or request.body + ) + for k, v in request.context.items(): + if k not in all_available_args: + all_available_args[k] = v + + if len(required_arg_names) > 0: + # To support instance/class methods in a class for listeners/middleware, + # check if the first argument is either self or cls + first_arg_name = required_arg_names[0] + if first_arg_name in {"self", "cls"}: + required_arg_names.pop(0) + elif first_arg_name not in all_available_args.keys() and first_arg_name != "args": + if this_func is None: + logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) + required_arg_names.pop(0) + elif inspect.ismethod(this_func): + # We are sure that we should skip manipulating this arg + required_arg_names.pop(0) + + kwargs: Dict[str, Any] = {k: v for k, v in all_available_args.items() if k in required_arg_names} found_arg_names = kwargs.keys() for name in required_arg_names: if name == "args": if isinstance(request, BoltRequest): kwargs[name] = Args(**all_available_args) else: - logger.warning( - f"Unknown Request object type detected ({type(request)})" - ) + logger.warning(f"Unknown Request object type detected ({type(request)})") - if name not in found_arg_names: + elif name not in found_arg_names: logger.warning(f"{name} is not a valid argument") kwargs[name] = None return kwargs diff --git a/slack_bolt/lazy_listener/__init__.py b/slack_bolt/lazy_listener/__init__.py new file mode 100644 index 000000000..a92c18483 --- /dev/null +++ b/slack_bolt/lazy_listener/__init__.py @@ -0,0 +1,32 @@ +"""Lazy listener runner is a beta feature for the apps running on Function-as-a-Service platforms. + + def respond_to_slack_within_3_seconds(body, ack): + text = body.get("text") + if text is None or len(text) == 0: + ack(f":x: Usage: /start-process (description here)") + else: + ack(f"Accepted! (task: {body['text']})") + + import time + def run_long_process(respond, body): + time.sleep(5) # longer than 3 seconds + respond(f"Completed! (task: {body['text']})") + + app.command("/start-process")( + # ack() is still called within 3 seconds + ack=respond_to_slack_within_3_seconds, + # Lazy function is responsible for processing the event + lazy=[run_long_process] + ) + +Refer to https://docs.slack.dev/tools/bolt-python/concepts/lazy-listeners for more details. +""" + +# Don't add async module imports here +from .runner import LazyListenerRunner +from .thread_runner import ThreadLazyListenerRunner + +__all__ = [ + "LazyListenerRunner", + "ThreadLazyListenerRunner", +] diff --git a/slack_bolt/lazy_listener/async_internals.py b/slack_bolt/lazy_listener/async_internals.py new file mode 100644 index 000000000..e1134170c --- /dev/null +++ b/slack_bolt/lazy_listener/async_internals.py @@ -0,0 +1,32 @@ +from functools import wraps +from logging import Logger +from typing import Callable, Awaitable + +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.util.utils import get_arg_names_of_callable + + +async def to_runnable_function( + internal_func: Callable[..., Awaitable[None]], + logger: Logger, + request: AsyncBoltRequest, +): + arg_names = get_arg_names_of_callable(internal_func) + + @wraps(internal_func) + async def request_wired_wrapper() -> None: + try: + await internal_func( + **build_async_required_kwargs( + logger=logger, + required_arg_names=arg_names, + request=request, + response=None, + this_func=internal_func, + ) + ) + except Exception as e: + logger.error(f"Failed to run an internal function ({e})") + + return await request_wired_wrapper() diff --git a/slack_bolt/lazy_listener/async_runner.py b/slack_bolt/lazy_listener/async_runner.py new file mode 100644 index 000000000..976d404ed --- /dev/null +++ b/slack_bolt/lazy_listener/async_runner.py @@ -0,0 +1,34 @@ +from abc import abstractmethod, ABCMeta +from logging import Logger +from typing import Callable, Awaitable + +from slack_bolt.lazy_listener.async_internals import to_runnable_function +from slack_bolt.request.async_request import AsyncBoltRequest + + +class AsyncLazyListenerRunner(metaclass=ABCMeta): + logger: Logger + + @abstractmethod + def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: + """Starts a new lazy listener execution. + + Args: + function: The function to run. + request: The request to pass to the function. The object must be thread-safe. + """ + raise NotImplementedError() + + async def run(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: + """Synchronously run the function with a given request data. + + Args: + function: The function to run. + request: The request to pass to the function. The object must be thread-safe. + """ + func = to_runnable_function( + internal_func=function, + logger=self.logger, + request=request, + ) + return await func() # type: ignore[operator] diff --git a/slack_bolt/lazy_listener/asyncio_runner.py b/slack_bolt/lazy_listener/asyncio_runner.py new file mode 100644 index 000000000..7c88e5d1d --- /dev/null +++ b/slack_bolt/lazy_listener/asyncio_runner.py @@ -0,0 +1,26 @@ +import asyncio +from logging import Logger +from typing import Callable, Awaitable + +from slack_bolt.lazy_listener.async_internals import to_runnable_function +from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner +from slack_bolt.request.async_request import AsyncBoltRequest + + +class AsyncioLazyListenerRunner(AsyncLazyListenerRunner): + logger: Logger + + def __init__( + self, + logger: Logger, + ): + self.logger = logger + + def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: + asyncio.ensure_future( + to_runnable_function( + internal_func=function, + logger=self.logger, + request=request, + ) + ) diff --git a/slack_bolt/lazy_listener/internals.py b/slack_bolt/lazy_listener/internals.py new file mode 100644 index 000000000..ce088c67e --- /dev/null +++ b/slack_bolt/lazy_listener/internals.py @@ -0,0 +1,32 @@ +from functools import wraps +from logging import Logger +from typing import Callable + +from slack_bolt.kwargs_injection import build_required_kwargs +from slack_bolt.request import BoltRequest +from slack_bolt.util.utils import get_arg_names_of_callable + + +def build_runnable_function( + func: Callable[..., None], + logger: Logger, + request: BoltRequest, +) -> Callable[[], None]: + arg_names = get_arg_names_of_callable(func) + + @wraps(func) + def request_wired_func_wrapper() -> None: + try: + func( + **build_required_kwargs( + logger=logger, + required_arg_names=arg_names, + request=request, + response=None, + this_func=func, + ) + ) + except Exception as e: + logger.error(f"Failed to run an internal function ({e})") + + return request_wired_func_wrapper diff --git a/slack_bolt/lazy_listener/runner.py b/slack_bolt/lazy_listener/runner.py new file mode 100644 index 000000000..a9b55723c --- /dev/null +++ b/slack_bolt/lazy_listener/runner.py @@ -0,0 +1,33 @@ +from abc import abstractmethod, ABCMeta +from logging import Logger +from typing import Callable + +from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.request import BoltRequest + + +class LazyListenerRunner(metaclass=ABCMeta): + logger: Logger + + @abstractmethod + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + """Starts a new lazy listener execution. + + Args: + function: The function to run. + request: The request to pass to the function. The object must be thread-safe. + """ + raise NotImplementedError() + + def run(self, function: Callable[..., None], request: BoltRequest) -> None: + """Synchronously runs the function with a given request data. + + Args: + function: The function to run. + request: The request to pass to the function. The object must be thread-safe. + """ + build_runnable_function( + func=function, + logger=self.logger, + request=request, + )() diff --git a/slack_bolt/lazy_listener/thread_runner.py b/slack_bolt/lazy_listener/thread_runner.py new file mode 100644 index 000000000..3720f9ac5 --- /dev/null +++ b/slack_bolt/lazy_listener/thread_runner.py @@ -0,0 +1,28 @@ +from concurrent.futures import Executor +from logging import Logger +from typing import Callable + +from slack_bolt.lazy_listener.internals import build_runnable_function +from slack_bolt.lazy_listener.runner import LazyListenerRunner +from slack_bolt.request import BoltRequest + + +class ThreadLazyListenerRunner(LazyListenerRunner): + logger: Logger + + def __init__( + self, + logger: Logger, + executor: Executor, + ): + self.logger = logger + self.executor = executor + + def start(self, function: Callable[..., None], request: BoltRequest) -> None: + self.executor.submit( + build_runnable_function( + func=function, + logger=self.logger, + request=request, + ) + ) diff --git a/slack_bolt/listener/__init__.py b/slack_bolt/listener/__init__.py index c7de1c659..a12a2b821 100644 --- a/slack_bolt/listener/__init__.py +++ b/slack_bolt/listener/__init__.py @@ -1,3 +1,8 @@ +"""Listeners process an incoming request from Slack if the request's type or data structure matches +the predefined conditions of the listener. Typically, a listener acknowledge requests from Slack, +process the request data, and may send response back to Slack. +""" + # Don't add async module imports here from .custom_listener import CustomListener from .listener import Listener @@ -7,3 +12,9 @@ ] for cls in builtin_listener_classes: Listener.register(cls) + +__all__ = [ + "CustomListener", + "Listener", + "builtin_listener_classes", +] diff --git a/slack_bolt/listener/async_builtins.py b/slack_bolt/listener/async_builtins.py new file mode 100644 index 000000000..7b3bc4da7 --- /dev/null +++ b/slack_bolt/listener/async_builtins.py @@ -0,0 +1,35 @@ +from slack_bolt.context.async_context import AsyncBoltContext +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) + + +class AsyncTokenRevocationListeners: + """Listener functions to handle token revocation / uninstallation events""" + + installation_store: AsyncInstallationStore + + def __init__(self, installation_store: AsyncInstallationStore): + self.installation_store = installation_store + + async def handle_tokens_revoked_events(self, event: dict, context: AsyncBoltContext) -> None: + user_ids = event.get("tokens", {}).get("oauth", []) + if len(user_ids) > 0: + for user_id in user_ids: + await self.installation_store.async_delete_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + ) + bots = event.get("tokens", {}).get("bot", []) + if len(bots) > 0: + await self.installation_store.async_delete_bot( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + + async def handle_app_uninstalled_events(self, context: AsyncBoltContext) -> None: + await self.installation_store.async_delete_all( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 55186847e..1717b1a8d 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -1,21 +1,27 @@ from abc import abstractmethod, ABCMeta -from typing import List, Callable, Awaitable, Tuple +from typing import Callable, Awaitable, MutableSequence, Tuple, Optional, Sequence from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from ..kwargs_injection.async_utils import build_async_required_kwargs +from ..util.utils import get_arg_names_of_callable class AsyncListener(metaclass=ABCMeta): - matchers: List[AsyncListenerMatcher] - middleware: List[AsyncMiddleware] - func: Callable[..., Awaitable[BoltResponse]] + matchers: Sequence[AsyncListenerMatcher] + middleware: Sequence[AsyncMiddleware] + ack_function: Callable[..., Awaitable[BoltResponse]] + lazy_functions: Sequence[Callable[..., Awaitable[None]]] auto_acknowledgement: bool + ack_timeout: int async def async_matches( - self, *, req: AsyncBoltRequest, resp: BoltResponse, + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, ) -> bool: is_matched: bool = False for matcher in self.matchers: @@ -25,13 +31,19 @@ async def async_matches( return is_matched async def run_async_middleware( - self, *, req: AsyncBoltRequest, resp: BoltResponse, - ) -> Tuple[BoltResponse, bool]: - """ + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + ) -> Tuple[Optional[BoltResponse], bool]: + """Runs an async middleware. + + Args: + req: The incoming request + resp: The current response - :param req: the incoming request - :param resp: the current response - :return: a tuple of the processed response and a flag indicating termination + Returns: + A tuple of the processed response and a flag indicating termination """ for m in self.middleware: middleware_state = {"next_called": False} @@ -39,71 +51,76 @@ async def run_async_middleware( async def _next(): middleware_state["next_called"] = True - resp = await m.async_process(req=req, resp=resp, next=_next) + resp = await m.async_process(req=req, resp=resp, next=_next) # type: ignore[assignment] if not middleware_state["next_called"]: # next() was not called in this middleware return (resp, True) return (resp, False) @abstractmethod - async def run_ack_function( - self, *, request: AsyncBoltRequest, response: BoltResponse - ) -> BoltResponse: + async def run_ack_function(self, *, request: AsyncBoltRequest, response: BoltResponse) -> Optional[BoltResponse]: """Runs all the registered middleware and then run the listener function. - :param request: the incoming request - :param response: the current response - :return: the processed response + Args: + request: The incoming request + response: The current response + + Returns: + The processed response """ raise NotImplementedError() -import inspect from logging import Logger -from typing import Callable, List, Awaitable - -from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher from slack_bolt.logger import get_bolt_app_logger -from slack_bolt.middleware.async_middleware import AsyncMiddleware -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.response import BoltResponse class AsyncCustomListener(AsyncListener): app_name: str - func: Callable[..., Awaitable[BoltResponse]] - matchers: List[AsyncListenerMatcher] - middleware: List[AsyncMiddleware] + ack_function: Callable[..., Awaitable[Optional[BoltResponse]]] # type: ignore[assignment] + lazy_functions: Sequence[Callable[..., Awaitable[None]]] + matchers: Sequence[AsyncListenerMatcher] + middleware: Sequence[AsyncMiddleware] auto_acknowledgement: bool - arg_names: List[str] + ack_timeout: int + arg_names: MutableSequence[str] logger: Logger def __init__( self, *, app_name: str, - func: Callable[..., Awaitable[BoltResponse]], - matchers: List[AsyncListenerMatcher], - middleware: List[AsyncMiddleware], + ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], + lazy_functions: Sequence[Callable[..., Awaitable[None]]], + matchers: Sequence[AsyncListenerMatcher], + middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, + base_logger: Optional[Logger] = None, ): self.app_name = app_name - self.func = func + self.ack_function = ack_function + self.lazy_functions = lazy_functions self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(app_name, self.func) + self.ack_timeout = ack_timeout + self.arg_names = get_arg_names_of_callable(ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) async def run_ack_function( - self, *, request: AsyncBoltRequest, response: BoltResponse, - ) -> BoltResponse: - return await self.func( + self, + *, + request: AsyncBoltRequest, + response: BoltResponse, + ) -> Optional[BoltResponse]: + return await self.ack_function( **build_async_required_kwargs( logger=self.logger, required_arg_names=self.arg_names, request=request, response=response, + this_func=self.ack_function, ) ) diff --git a/slack_bolt/listener/async_listener_completion_handler.py b/slack_bolt/listener/async_listener_completion_handler.py new file mode 100644 index 000000000..9fc002a91 --- /dev/null +++ b/slack_bolt/listener/async_listener_completion_handler.py @@ -0,0 +1,57 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class AsyncListenerCompletionHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Do something extra after the listener execution + + Args: + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class AsyncCustomListenerCompletionHandler(AsyncListenerCompletionHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + request=request, + response=response, + next_keys_required=False, + ) + await self.func(**kwargs) + + +class AsyncDefaultListenerCompletionHandler(AsyncListenerCompletionHandler): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/async_listener_error_handler.py b/slack_bolt/listener/async_listener_error_handler.py new file mode 100644 index 000000000..b1a73458e --- /dev/null +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -0,0 +1,68 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class AsyncListenerErrorHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Handles an unhandled exception. + + Args: + error: The raised exception. + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + error=error, + request=request, + response=response, + next_keys_required=False, + ) + returned_response = await self.func(**kwargs) + if returned_response is not None and isinstance(returned_response, BoltResponse): + 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): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + message = f"Failed to run listener function (error: {error})" + self.logger.exception(message) diff --git a/slack_bolt/listener/async_listener_start_handler.py b/slack_bolt/listener/async_listener_start_handler.py new file mode 100644 index 000000000..b7b10e9e7 --- /dev/null +++ b/slack_bolt/listener/async_listener_start_handler.py @@ -0,0 +1,57 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class AsyncListenerStartHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Do something extra before the listener execution + + Args: + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class AsyncCustomListenerStartHandler(AsyncListenerStartHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + request=request, + response=response, + next_keys_required=False, + ) + await self.func(**kwargs) + + +class AsyncDefaultListenerStartHandler(AsyncListenerStartHandler): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py new file mode 100644 index 000000000..81f9e6106 --- /dev/null +++ b/slack_bolt/listener/asyncio_runner.py @@ -0,0 +1,190 @@ +import asyncio +import time +from asyncio import Future +from logging import Logger +from typing import Optional, Callable, Awaitable + +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.lazy_listener.async_runner import AsyncLazyListenerRunner +from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.listener.async_listener_start_handler import ( + AsyncListenerStartHandler, +) +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncListenerCompletionHandler, +) +from slack_bolt.listener.async_listener_error_handler import AsyncListenerErrorHandler +from slack_bolt.logger.messages import ( + debug_responding, + debug_running_lazy_listener, + warning_did_not_call_ack, +) +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import create_copy, get_name_for_callable + + +class AsyncioListenerRunner: + logger: Logger + process_before_response: bool + listener_error_handler: AsyncListenerErrorHandler + listener_start_handler: AsyncListenerStartHandler + listener_completion_handler: AsyncListenerCompletionHandler + lazy_listener_runner: AsyncLazyListenerRunner + + def __init__( + self, + logger: Logger, + process_before_response: bool, + listener_error_handler: AsyncListenerErrorHandler, + listener_start_handler: AsyncListenerStartHandler, + listener_completion_handler: AsyncListenerCompletionHandler, + lazy_listener_runner: AsyncLazyListenerRunner, + ): + self.logger = logger + self.process_before_response = process_before_response + self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler + self.listener_completion_handler = listener_completion_handler + self.lazy_listener_runner = lazy_listener_runner + + async def run( + self, + request: AsyncBoltRequest, + response: BoltResponse, + listener_name: str, + listener: AsyncListener, + starting_time: Optional[float] = None, + ) -> Optional[BoltResponse]: + ack = request.context.ack + starting_time = starting_time if starting_time is not None else time.time() + if self.process_before_response: + if not request.lazy_only: + try: + await self.listener_start_handler.handle(request=request, response=response) + returned_value = await listener.run_ack_function(request=request, response=response) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and listener.auto_acknowledgement: + await ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + await self.listener_error_handler.handle( + error=e, + request=request, + response=response, + ) + ack.response = response + finally: + await self.listener_completion_handler.handle(request=request, response=response) + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = get_name_for_callable(lazy_func) + if func_name == request.lazy_function_name: + await self.lazy_listener_runner.run(function=lazy_func, request=request) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + if response is not None: + self._debug_log_completion(starting_time, response) + return response + elif ack.response is not None: + self._debug_log_completion(starting_time, ack.response) + return ack.response + else: + if listener.auto_acknowledgement: + # acknowledge immediately in case of Events API + await ack() + + if not request.lazy_only: + # start the listener function asynchronously + # NOTE: intentionally + async def run_ack_function_asynchronously( + ack: AsyncAck, + request: AsyncBoltRequest, + response: BoltResponse, + ): + try: + await self.listener_start_handler.handle(request=request, response=response) + await listener.run_ack_function(request=request, response=response) + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None # type: ignore[assignment] + + await self.listener_error_handler.handle( + error=e, + request=request, + response=response, + ) + ack.response = response + finally: + await self.listener_completion_handler.handle(request=request, response=response) + + _f: Future = asyncio.ensure_future(run_ack_function_asynchronously(ack, request, response)) + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = get_name_for_callable(lazy_func) + if func_name == request.lazy_function_name: + await self.lazy_listener_runner.run(function=lazy_func, request=request) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + # await for the completion of ack() in the async listener execution + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: + await asyncio.sleep(0.01) + + if response is None and ack.response is None: + self.logger.warning(warning_did_not_call_ack(listener_name)) + return None + + if response is None and ack.response is not None: + response = ack.response + self._debug_log_completion(starting_time, response) + return response + + if response is not None: + return response + + # None for both means no ack() in the listener + return None + + def _start_lazy_function(self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: + # Start a lazy function asynchronously + func_name: str = get_name_for_callable(lazy_func) + self.logger.debug(debug_running_lazy_listener(func_name)) + copied_request = self._build_lazy_request(request, func_name) + self.lazy_listener_runner.start(function=lazy_func, request=copied_request) + + def _build_lazy_request(self, request: AsyncBoltRequest, lazy_func_name: str) -> AsyncBoltRequest: + copied_request: AsyncBoltRequest = create_copy(request.to_copyable()) + copied_request.lazy_only = True + copied_request.lazy_function_name = lazy_func_name + copied_request.context["listener_runner"] = self + if request.context.get_thread_context is not None: + copied_request.context["get_thread_context"] = request.context.get_thread_context + if request.context.save_thread_context is not None: + copied_request.context["save_thread_context"] = request.context.save_thread_context + return copied_request + + def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None: + millis = int((time.time() - starting_time) * 1000) + self.logger.debug(debug_responding(response.status, response.body, millis)) diff --git a/slack_bolt/listener/builtins.py b/slack_bolt/listener/builtins.py new file mode 100644 index 000000000..ee5891f27 --- /dev/null +++ b/slack_bolt/listener/builtins.py @@ -0,0 +1,33 @@ +from slack_bolt.context.context import BoltContext +from slack_sdk.oauth.installation_store.installation_store import InstallationStore + + +class TokenRevocationListeners: + """Listener functions to handle token revocation / uninstallation events""" + + installation_store: InstallationStore + + def __init__(self, installation_store: InstallationStore): + self.installation_store = installation_store + + def handle_tokens_revoked_events(self, event: dict, context: BoltContext) -> None: + user_ids = event.get("tokens", {}).get("oauth", []) + if len(user_ids) > 0: + for user_id in user_ids: + self.installation_store.delete_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + ) + bots = event.get("tokens", {}).get("bot", []) + if len(bots) > 0: + self.installation_store.delete_bot( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + + def handle_app_uninstalled_events(self, context: BoltContext) -> None: + self.installation_store.delete_all( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index 8f4cfbf9c..2b73018db 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -1,6 +1,5 @@ -import inspect from logging import Logger -from typing import Callable, List +from typing import Callable, MutableSequence, Optional, Sequence from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.listener_matcher import ListenerMatcher @@ -9,42 +8,54 @@ from .listener import Listener from ..logger import get_bolt_app_logger from ..middleware import Middleware +from ..util.utils import get_arg_names_of_callable class CustomListener(Listener): app_name: str - func: Callable[..., BoltResponse] - matchers: List[ListenerMatcher] - middleware: List[Middleware] # type: ignore + ack_function: Callable[..., Optional[BoltResponse]] # type: ignore[assignment] + lazy_functions: Sequence[Callable[..., None]] + matchers: Sequence[ListenerMatcher] + middleware: Sequence[Middleware] auto_acknowledgement: bool - arg_names: List[str] + ack_timeout: int = 3 + arg_names: MutableSequence[str] logger: Logger def __init__( self, *, app_name: str, - func: Callable[..., BoltResponse], - matchers: List[ListenerMatcher], - middleware: List[Middleware], # type: ignore + ack_function: Callable[..., Optional[BoltResponse]], + lazy_functions: Sequence[Callable[..., None]], + matchers: Sequence[ListenerMatcher], + middleware: Sequence[Middleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, + base_logger: Optional[Logger] = None, ): self.app_name = app_name - self.func = func + self.ack_function = ack_function + self.lazy_functions = lazy_functions self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(app_name, self.func) + self.ack_timeout = ack_timeout + self.arg_names = get_arg_names_of_callable(ack_function) + self.logger = get_bolt_app_logger(app_name, self.ack_function, base_logger) def run_ack_function( - self, *, request: BoltRequest, response: BoltResponse, - ) -> BoltResponse: - return self.func( + self, + *, + request: BoltRequest, + response: BoltResponse, + ) -> Optional[BoltResponse]: + return self.ack_function( **build_required_kwargs( logger=self.logger, required_arg_names=self.arg_names, request=request, response=response, + this_func=self.ack_function, ) ) diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index c097c09e3..7685f3c7b 100644 --- a/slack_bolt/listener/listener.py +++ b/slack_bolt/listener/listener.py @@ -1,5 +1,5 @@ from abc import abstractmethod, ABCMeta -from typing import List, Callable, Tuple +from typing import Callable, Tuple, Sequence, Optional from slack_bolt.listener_matcher import ListenerMatcher from slack_bolt.middleware import Middleware @@ -8,12 +8,19 @@ class Listener(metaclass=ABCMeta): - matchers: List[ListenerMatcher] - middleware: List[Middleware] # type: ignore - func: Callable[..., BoltResponse] + matchers: Sequence[ListenerMatcher] + middleware: Sequence[Middleware] + ack_function: Callable[..., BoltResponse] + lazy_functions: Sequence[Callable[..., None]] auto_acknowledgement: bool + ack_timeout: int = 3 - def matches(self, *, req: BoltRequest, resp: BoltResponse,) -> bool: + def matches( + self, + *, + req: BoltRequest, + resp: BoltResponse, + ) -> bool: is_matched: bool = False for matcher in self.matchers: is_matched = matcher.matches(req, resp) @@ -22,34 +29,41 @@ def matches(self, *, req: BoltRequest, resp: BoltResponse,) -> bool: return is_matched def run_middleware( - self, *, req: BoltRequest, resp: BoltResponse, - ) -> Tuple[BoltResponse, bool]: - """ + self, + *, + req: BoltRequest, + resp: BoltResponse, + ) -> Tuple[Optional[BoltResponse], bool]: + """Runs a middleware. + + Args: + req: The incoming request + resp: The current response - :param req: the incoming request - :param resp: the current response - :return: a tuple of the processed response and a flag indicating termination + Returns: + A tuple of the processed response and a flag indicating termination """ for m in self.middleware: middleware_state = {"next_called": False} - def next(): + def next_(): middleware_state["next_called"] = True - resp = m.process(req=req, resp=resp, next=next) + resp = m.process(req=req, resp=resp, next=next_) # type: ignore[assignment] if not middleware_state["next_called"]: # next() was not called in this middleware return (resp, True) return (resp, False) @abstractmethod - def run_ack_function( - self, *, request: BoltRequest, response: BoltResponse - ) -> BoltResponse: + def run_ack_function(self, *, request: BoltRequest, response: BoltResponse) -> Optional[BoltResponse]: """Runs all the registered middleware and then run the listener function. - :param request: the incoming request - :param response: the current response - :return: the processed response + Args: + request: The incoming request + response: The current response + + Returns: + The processed response """ raise NotImplementedError() diff --git a/slack_bolt/listener/listener_completion_handler.py b/slack_bolt/listener/listener_completion_handler.py new file mode 100644 index 000000000..b2c08a205 --- /dev/null +++ b/slack_bolt/listener/listener_completion_handler.py @@ -0,0 +1,57 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Optional + +from slack_bolt.kwargs_injection import build_required_kwargs +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class ListenerCompletionHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Do something extra after the listener execution + + Args: + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class CustomListenerCompletionHandler(ListenerCompletionHandler): + def __init__(self, logger: Logger, func: Callable[..., None]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + request=request, + response=response, + next_keys_required=False, + ) + self.func(**kwargs) + + +class DefaultListenerCompletionHandler(ListenerCompletionHandler): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/listener_error_handler.py b/slack_bolt/listener/listener_error_handler.py new file mode 100644 index 000000000..7dd6d066b --- /dev/null +++ b/slack_bolt/listener/listener_error_handler.py @@ -0,0 +1,68 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Optional + +from slack_bolt.kwargs_injection import build_required_kwargs +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class ListenerErrorHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Handles an unhandled exception. + + Args: + error: The raised exception. + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class CustomListenerErrorHandler(ListenerErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + def handle( + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], + ): + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + error=error, + request=request, + response=response, + next_keys_required=False, + ) + returned_response = self.func(**kwargs) + if returned_response is not None and isinstance(returned_response, BoltResponse): + 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): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], + ): + message = f"Failed to run listener function (error: {error})" + self.logger.exception(message) diff --git a/slack_bolt/listener/listener_start_handler.py b/slack_bolt/listener/listener_start_handler.py new file mode 100644 index 000000000..265313a82 --- /dev/null +++ b/slack_bolt/listener/listener_start_handler.py @@ -0,0 +1,61 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Optional + +from slack_bolt.kwargs_injection import build_required_kwargs +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class ListenerStartHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Do something extra before the listener execution. + + This handler is useful if a developer needs to maintain/clean up + thread-local resources such as Django ORM database connections + before a listener execution starts. + + Args: + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class CustomListenerStartHandler(ListenerStartHandler): + def __init__(self, logger: Logger, func: Callable[..., None]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + request=request, + response=response, + next_keys_required=False, + ) + self.func(**kwargs) + + +class DefaultListenerStartHandler(ListenerStartHandler): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, + request: BoltRequest, + response: Optional[BoltResponse], + ): + pass diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py new file mode 100644 index 000000000..378ca1bfa --- /dev/null +++ b/slack_bolt/listener/thread_runner.py @@ -0,0 +1,202 @@ +import time +from concurrent.futures import Executor +from logging import Logger +from typing import Optional, Callable + +from slack_bolt.lazy_listener import LazyListenerRunner +from slack_bolt.listener import Listener +from slack_bolt.listener.listener_start_handler import ListenerStartHandler +from slack_bolt.listener.listener_completion_handler import ListenerCompletionHandler +from slack_bolt.listener.listener_error_handler import ListenerErrorHandler +from slack_bolt.logger.messages import ( + debug_responding, + debug_running_lazy_listener, + warning_did_not_call_ack, +) +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import create_copy, get_name_for_callable + + +class ThreadListenerRunner: + logger: Logger + process_before_response: bool + listener_error_handler: ListenerErrorHandler + listener_start_handler: ListenerStartHandler + listener_completion_handler: ListenerCompletionHandler + listener_executor: Executor + lazy_listener_runner: LazyListenerRunner + + def __init__( + self, + logger: Logger, + process_before_response: bool, + listener_error_handler: ListenerErrorHandler, + listener_start_handler: ListenerStartHandler, + listener_completion_handler: ListenerCompletionHandler, + listener_executor: Executor, + lazy_listener_runner: LazyListenerRunner, + ): + self.logger = logger + self.process_before_response = process_before_response + self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler + self.listener_completion_handler = listener_completion_handler + self.listener_executor = listener_executor + self.lazy_listener_runner = lazy_listener_runner + + def run( + self, + request: BoltRequest, + response: BoltResponse, + listener_name: str, + listener: Listener, + starting_time: Optional[float] = None, + ) -> Optional[BoltResponse]: + ack = request.context.ack + starting_time = starting_time if starting_time is not None else time.time() + if self.process_before_response: + if not request.lazy_only: + try: + self.listener_start_handler.handle( + request=request, + response=response, + ) + returned_value = listener.run_ack_function(request=request, response=response) + if isinstance(returned_value, BoltResponse): + response = returned_value + if ack.response is None and listener.auto_acknowledgement: + ack() # automatic ack() call if the call is not yet done + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if response is None: + response = BoltResponse(status=500) + response.status = 500 + self.listener_error_handler.handle( + error=e, + request=request, + response=response, + ) + ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = get_name_for_callable(lazy_func) + if func_name == request.lazy_function_name: + self.lazy_listener_runner.run(function=lazy_func, request=request) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + if response is not None: + self._debug_log_completion(starting_time, response) + return response + elif ack.response is not None: + self._debug_log_completion(starting_time, ack.response) + return ack.response + else: + if listener.auto_acknowledgement: + # acknowledge immediately in case of Events API + ack() + + if not request.lazy_only: + # start the listener function asynchronously + def run_ack_function_asynchronously(): + nonlocal response + try: + self.listener_start_handler.handle( + request=request, + response=response, + ) + listener.run_ack_function(request=request, response=response) + except Exception as e: + # The default response status code is 500 in this case. + # You can customize this by passing your own error handler. + if listener.auto_acknowledgement: + self.listener_error_handler.handle( + error=e, + request=request, + response=response, + ) + else: + if response is None: + response = BoltResponse(status=500) + response.status = 500 + if ack.response is not None: # already acknowledged + response = None + self.listener_error_handler.handle( + error=e, + request=request, + response=response, + ) + ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) + + self.listener_executor.submit(run_ack_function_asynchronously) + + for lazy_func in listener.lazy_functions: + if request.lazy_function_name: + func_name = get_name_for_callable(lazy_func) + if func_name == request.lazy_function_name: + self.lazy_listener_runner.run(function=lazy_func, request=request) + # This HTTP response won't be sent to Slack API servers. + return BoltResponse(status=200) + else: + continue + else: + self._start_lazy_function(lazy_func, request) + + # await for the completion of ack() in the async listener execution + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: + time.sleep(0.01) + + if response is None and ack.response is None: + self.logger.warning(warning_did_not_call_ack(listener_name)) + return None + + if response is None and ack.response is not None: + response = ack.response + self._debug_log_completion(starting_time, response) + return response + + if response is not None: + return response + + # None for both means no ack() in the listener + return None + + def _start_lazy_function(self, lazy_func: Callable[..., None], request: BoltRequest) -> None: + # Start a lazy function asynchronously + func_name: str = get_name_for_callable(lazy_func) + self.logger.debug(debug_running_lazy_listener(func_name)) + copied_request = self._build_lazy_request(request, func_name) + self.lazy_listener_runner.start(function=lazy_func, request=copied_request) + + def _build_lazy_request(self, request: BoltRequest, lazy_func_name: str) -> BoltRequest: + copied_request: BoltRequest = create_copy(request.to_copyable()) + copied_request.lazy_only = True + copied_request.lazy_function_name = lazy_func_name + # These are not copyable objects, so manually set for a different thread + copied_request.context["listener_runner"] = self + if request.context.get_thread_context is not None: + copied_request.context["get_thread_context"] = request.context.get_thread_context + if request.context.save_thread_context is not None: + copied_request.context["save_thread_context"] = request.context.save_thread_context + return copied_request + + def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None: + millis = int((time.time() - starting_time) * 1000) + self.logger.debug(debug_responding(response.status, response.body, millis)) diff --git a/slack_bolt/listener_matcher/__init__.py b/slack_bolt/listener_matcher/__init__.py index 7a2ee75de..26f164ba6 100644 --- a/slack_bolt/listener_matcher/__init__.py +++ b/slack_bolt/listener_matcher/__init__.py @@ -1,3 +1,8 @@ +"""A listener matcher is a simplified version of listener middleware. +A listener matcher function returns bool value instead of `next()` method invocation inside. +This interface enables developers to utilize simple predicate functions for additional listener conditions. +""" + # Don't add async module imports here from .custom_listener_matcher import CustomListenerMatcher from .listener_matcher import ListenerMatcher @@ -7,3 +12,9 @@ ] for cls in builtin_listener_matcher_classes: ListenerMatcher.register(cls) + +__all__ = [ + "CustomListenerMatcher", + "ListenerMatcher", + "builtin_listener_matcher_classes", +] diff --git a/slack_bolt/listener_matcher/async_builtins.py b/slack_bolt/listener_matcher/async_builtins.py index 95a7b32e8..93f5e6190 100644 --- a/slack_bolt/listener_matcher/async_builtins.py +++ b/slack_bolt/listener_matcher/async_builtins.py @@ -1,4 +1,3 @@ -# pytype: skip-file from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_listener_matcher import AsyncListenerMatcher @@ -8,11 +7,12 @@ class AsyncBuiltinListenerMatcher(BuiltinListenerMatcher, AsyncListenerMatcher): async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: - return await self.func( + return await self.func( # type: ignore[misc] **build_async_required_kwargs( logger=self.logger, required_arg_names=self.arg_names, request=req, response=resp, + this_func=self.func, ) ) diff --git a/slack_bolt/listener_matcher/async_listener_matcher.py b/slack_bolt/listener_matcher/async_listener_matcher.py index ed53d7719..3230bb342 100644 --- a/slack_bolt/listener_matcher/async_listener_matcher.py +++ b/slack_bolt/listener_matcher/async_listener_matcher.py @@ -2,43 +2,51 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable class AsyncListenerMatcher(metaclass=ABCMeta): @abstractmethod async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: + """Matches against the request and returns True if matched. + + Args: + req: The request + resp: The response + + Returns: + True if matched + """ raise NotImplementedError() -import inspect from logging import Logger -from typing import Callable, Awaitable, List +from typing import Callable, Awaitable, Sequence, Optional from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.response import BoltResponse class AsyncCustomListenerMatcher(AsyncListenerMatcher): app_name: str func: Callable[..., Awaitable[bool]] - arg_names: List[str] + arg_names: Sequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]]): + def __init__(self, *, app_name: str, func: Callable[..., Awaitable[bool]], base_logger: Optional[Logger] = None): self.app_name = app_name self.func = func - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.arg_names = get_arg_names_of_callable(func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: return await self.func( **build_async_required_kwargs( logger=self.logger, - required_arg_names=self.arg_names, + required_arg_names=self.arg_names, # type: ignore[arg-type] request=req, response=resp, + this_func=self.func, ) ) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 2794e9050..76c12d452 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -1,15 +1,31 @@ -# pytype: skip-file -import inspect -import sys - -from ..error import BoltError - -if sys.version_info.major == 3 and sys.version_info.minor <= 6: - from re import _pattern_type as Pattern -else: - from re import Pattern -from typing import Callable, Awaitable -from typing import Union, Optional, Dict +import re +from logging import Logger + +from slack_bolt.error import BoltError +from slack_bolt.request.payload_utils import ( + is_block_actions, + is_function, + is_global_shortcut, + is_message_shortcut, + is_attachment_action, + is_dialog_submission, + is_dialog_cancellation, + is_workflow_step_edit, + is_slash_command, + is_event, + is_view_submission, + is_view_closed, + is_block_suggestion, + is_dialog_suggestion, + is_shortcut, + to_action, + is_workflow_step_save, +) +from ..logger.messages import error_message_event_type +from ..util.utils import get_arg_names_of_callable + +from re import Pattern +from typing import Callable, Awaitable, Any, Sequence, Optional, Union, Dict from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.request import BoltRequest @@ -20,34 +36,42 @@ # a.k.a Union[ListenerMatcher, "AsyncListenerMatcher"] class BuiltinListenerMatcher(ListenerMatcher): - def __init__(self, *, func: Callable[..., Union[bool, Awaitable[bool]]]): + def __init__( + self, + *, + func: Callable[..., Union[bool, Awaitable[bool]]], + base_logger: Optional[Logger] = None, + ): self.func = func - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_logger(self.func) + self.arg_names = get_arg_names_of_callable(func) + self.logger = get_bolt_logger(self.func, base_logger) def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: - return self.func( + return self.func( # type: ignore[return-value] **build_required_kwargs( logger=self.logger, required_arg_names=self.arg_names, request=req, response=resp, + this_func=self.func, ) ) def build_listener_matcher( - func: Callable[..., bool], asyncio: bool, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + func: Callable[..., bool], + asyncio: bool, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] if asyncio: from .async_builtins import AsyncBuiltinListenerMatcher - async def async_fun(payload: dict) -> bool: - return func(payload) + async def async_fun(body: Dict[str, Any]) -> bool: + return func(body) - return AsyncBuiltinListenerMatcher(func=async_fun) + return AsyncBuiltinListenerMatcher(func=async_fun, base_logger=base_logger) else: - return BuiltinListenerMatcher(func=func) + return BuiltinListenerMatcher(func=func, base_logger=base_logger) # ------------- @@ -55,46 +79,124 @@ async def async_fun(payload: dict) -> bool: def event( - constraints: Union[str, Pattern, Dict[str, str]], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - if constraints == "message": - # matches message events that don't have subtype in payload - constraints = {"type": "message", "subtype": None} - + constraints: Union[ + str, + Pattern, + Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], + ], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] if isinstance(constraints, (str, Pattern)): event_type: Union[str, Pattern] = constraints + _verify_message_event_type(event_type) - def func(payload: dict) -> bool: - return _is_valid_event_payload(payload) and _matches( - event_type, payload["event"]["type"] - ) + def func(body: Dict[str, Any]) -> bool: + return is_event(body) and _matches(event_type, body["event"]["type"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: + _verify_message_event_type(constraints["type"]) # type: ignore[arg-type] + + def func(body: Dict[str, Any]) -> bool: + if is_event(body): + return _check_event_subtype( + event_payload=body["event"], + constraints=constraints, + ) + return False + + return build_listener_matcher(func, asyncio, base_logger) + + raise BoltError(f"event ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict") + + +def message_event( + constraints: Dict[str, Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]]], + keyword: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + if "type" in constraints and keyword is not None: + _verify_message_event_type(constraints["type"]) # type: ignore[arg-type] + + def func(body: Dict[str, Any]) -> bool: + if is_event(body): + is_valid_subtype = _check_event_subtype( + event_payload=body["event"], + constraints=constraints, + ) + if is_valid_subtype is True: + # Check keyword matching + text = body.get("event", {}).get("text", "") + match_result = re.findall(keyword, text) + if match_result is not None and match_result != []: + return True + return False + + return build_listener_matcher(func, asyncio, base_logger) - def func(payload: dict) -> bool: - if _is_valid_event_payload(payload): - event = payload["event"] - if not _matches(constraints["type"], event["type"]): - return False - if "subtype" in constraints: - expected_subtype = constraints["subtype"] - if expected_subtype is None: - # "subtype" in constraints is intentionally None for this pattern - return "subtype" not in event - else: - return "subtype" in event and _matches( - expected_subtype, event["subtype"] - ) - return True + raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict") + + +def _check_event_subtype(event_payload: dict, constraints: dict) -> bool: + if not _matches(constraints["type"], event_payload["type"]): + return False + if "subtype" in constraints: + expected_subtype: Optional[Union[str, Sequence[Optional[Union[str, Pattern]]]]] = constraints["subtype"] + if expected_subtype is None: + # "subtype" in constraints is intentionally None for this pattern + return "subtype" not in event_payload + elif isinstance(expected_subtype, (str, Pattern)): + return "subtype" in event_payload and _matches(expected_subtype, event_payload["subtype"]) + elif isinstance(expected_subtype, Sequence): + subtypes: Sequence[Optional[Union[str, Pattern]]] = expected_subtype + for expected in subtypes: + actual: Optional[str] = event_payload.get("subtype") + if expected is None: + if actual is None: + return True + elif actual is not None and _matches(expected, actual): + return True return False + else: + return "subtype" in event_payload and _matches(expected_subtype, event_payload["subtype"]) + return True + - return build_listener_matcher(func, asyncio) +def _verify_message_event_type(event_type: Union[str, Pattern]) -> None: + if isinstance(event_type, str) and event_type.startswith("message."): + raise ValueError(error_message_event_type(event_type)) + if isinstance(event_type, Pattern) and "message\\." in event_type.pattern: + raise ValueError(error_message_event_type(event_type)) + + +def function_executed( + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return is_function(body) and _matches(callback_id, body.get("event", {}).get("function", {}).get("callback_id", "")) + + return build_listener_matcher(func, asyncio, base_logger) + + +def workflow_step_execute( + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return ( + is_event(body) + and _matches("workflow_step_execute", body["event"]["type"]) + and "workflow_step" in body["event"] + and _matches(callback_id, body["event"]["callback_id"]) + ) - raise BoltError( - f"event ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" - ) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -102,14 +204,14 @@ def func(payload: dict) -> bool: def command( - command: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload and "command" in payload and _matches(command, payload["command"]) - ) + command: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return is_slash_command(body) and _matches(command, body["command"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -119,19 +221,15 @@ def func(payload: dict) -> bool: def shortcut( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] if isinstance(constraints, (str, Pattern)): callback_id: Union[str, Pattern] = constraints - def func(payload: dict) -> bool: - return ( - payload - and "callback_id" in payload - and payload["type"] == "shortcut" - and _matches(callback_id, payload["callback_id"]) - ) + def func(body: Dict[str, Any]) -> bool: + return is_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints and "callback_id" in constraints: if constraints["type"] == "shortcut": @@ -139,37 +237,29 @@ def func(payload: dict) -> bool: if constraints["type"] == "message_action": return message_shortcut(constraints["callback_id"], asyncio) - raise BoltError( - f"shortcut ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" - ) + raise BoltError(f"shortcut ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict") def global_shortcut( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "shortcut") - and "callback_id" in payload - and _matches(callback_id, payload["callback_id"]) - ) + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return is_global_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def message_shortcut( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "message_action") - and "callback_id" in payload - and _matches(callback_id, payload["callback_id"]) - ) + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return is_message_shortcut(body) and _matches(callback_id, body["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -179,79 +269,146 @@ def func(payload: dict) -> bool: def action( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] if isinstance(constraints, (str, Pattern)): - return block_action(constraints, asyncio) + + def func(body: Dict[str, Any]) -> bool: + return ( + _block_action(constraints, body) + or _attachment_action(constraints, body) + or _dialog_submission(constraints, body) + or _dialog_cancellation(constraints, body) + or _workflow_step_edit(constraints, body) + ) + + return build_listener_matcher(func, asyncio, base_logger) + elif "type" in constraints: action_type = constraints["type"] if action_type == "block_actions": - return block_action(constraints["action_id"], asyncio) + return block_action(constraints, asyncio) if action_type == "interactive_message": return attachment_action(constraints["callback_id"], asyncio) if action_type == "dialog_submission": return dialog_submission(constraints["callback_id"], asyncio) if action_type == "dialog_cancellation": return dialog_cancellation(constraints["callback_id"], asyncio) + # https://docs.slack.dev/legacy/legacy-steps-from-apps/ + if action_type == "workflow_step_edit": + return workflow_step_edit(constraints["callback_id"], asyncio) raise BoltError(f"type: {action_type} is unsupported") + elif "action_id" in constraints or "block_id" in constraints: + # The default value is "block_actions" + return block_action(constraints, asyncio) - raise BoltError( - f"action ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" - ) + raise BoltError(f"action ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict") + + +def _block_action( + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], + body: Dict[str, Any], +) -> bool: + if is_block_actions(body) is False: + return False + + action = to_action(body) + if isinstance(constraints, (str, Pattern)): + action_id = constraints + return _matches(action_id, action["action_id"]) # type: ignore[index] + elif isinstance(constraints, dict): + # block_id matching is optional + block_id: Optional[Union[str, Pattern]] = constraints.get("block_id") + action_id = constraints.get("action_id") # type: ignore[assignment] + if block_id is None and action_id is None: + return False + block_id_matched = block_id is None or _matches(block_id, action.get("block_id")) # type: ignore[union-attr] + action_id_matched = action_id is None or _matches(action_id, action.get("action_id")) # type: ignore[union-attr] + return block_id_matched and action_id_matched def block_action( - action_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "block_actions") - and "actions" in payload - and _matches(action_id, payload["actions"][0]["action_id"]) - ) + constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return _block_action(constraints, body) + + return build_listener_matcher(func, asyncio, base_logger) - return build_listener_matcher(func, asyncio) + +def _attachment_action( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: + return is_attachment_action(body) and _matches(callback_id, body["callback_id"]) def attachment_action( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "interactive_message") - and "actions" in payload - and _matches(callback_id, payload["callback_id"]) - ) + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return _attachment_action(callback_id, body) + + return build_listener_matcher(func, asyncio, base_logger) + - return build_listener_matcher(func, asyncio) +def _dialog_submission( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: + return is_dialog_submission(body) and _matches(callback_id, body["callback_id"]) def dialog_submission( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "dialog_submission") - and _matches(callback_id, payload["callback_id"]) - ) + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return _dialog_submission(callback_id, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) + + +def _dialog_cancellation( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: + return is_dialog_cancellation(body) and _matches(callback_id, body["callback_id"]) def dialog_cancellation( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "dialog_cancellation") - and _matches(callback_id, payload["callback_id"]) - ) + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return _dialog_cancellation(callback_id, body) + + return build_listener_matcher(func, asyncio, base_logger) + - return build_listener_matcher(func, asyncio) +def _workflow_step_edit( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: + return is_workflow_step_edit(body) and _matches(callback_id, body["callback_id"]) + + +def workflow_step_edit( + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return _workflow_step_edit(callback_id, body) + + return build_listener_matcher(func, asyncio, base_logger) # ------------------------- @@ -261,7 +418,8 @@ def func(payload: dict) -> bool: def view( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] if isinstance(constraints, (str, Pattern)): return view_submission(constraints, asyncio) elif "type" in constraints: @@ -270,39 +428,40 @@ def view( if constraints["type"] == "view_closed": return view_closed(constraints["callback_id"], asyncio) - raise BoltError( - f"view ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" - ) + raise BoltError(f"view ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict") def view_submission( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "view_submission") - and "view" in payload - and "callback_id" in payload["view"] - and _matches(callback_id, payload["view"]["callback_id"]) - ) + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return is_view_submission(body) and _matches(callback_id, body["view"]["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) def view_closed( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "view_closed") - and "view" in payload - and "callback_id" in payload["view"] - and _matches(callback_id, payload["view"]["callback_id"]) - ) + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return is_view_closed(body) and _matches(callback_id, body["view"]["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) + + +def workflow_step_save( + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return is_workflow_step_save(body) and _matches(callback_id, body["view"]["callback_id"]) + + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -312,61 +471,60 @@ def func(payload: dict) -> bool: def options( constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] if isinstance(constraints, (str, Pattern)): - return block_suggestion(constraints, asyncio) + + def func(body: Dict[str, Any]) -> bool: + return _block_suggestion(constraints, body) or _dialog_suggestion(constraints, body) + + return build_listener_matcher(func, asyncio, base_logger) + if "action_id" in constraints: return block_suggestion(constraints["action_id"], asyncio) if "callback_id" in constraints: return dialog_suggestion(constraints["callback_id"], asyncio) else: - raise BoltError( - f"options ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict" - ) + raise BoltError(f"options ({constraints}: {type(constraints)}) must be any of str, Pattern, and dict") -def block_suggestion( - action_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "block_suggestion") - and "action_id" in payload - and _matches(action_id, payload["action_id"]) - ) +def _block_suggestion( + action_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: + return is_block_suggestion(body) and _matches(action_id, body["action_id"]) - return build_listener_matcher(func, asyncio) +def block_suggestion( + action_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return _block_suggestion(action_id, body) -def dialog_suggestion( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: - def func(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "dialog_suggestion") - and "callback_id" in payload - and _matches(callback_id, payload["callback_id"]) - ) + return build_listener_matcher(func, asyncio, base_logger) - return build_listener_matcher(func, asyncio) +def _dialog_suggestion( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: + return is_dialog_suggestion(body) and _matches(callback_id, body["callback_id"]) -# ------------------------- +def dialog_suggestion( + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: # type: ignore[name-defined] + def func(body: Dict[str, Any]) -> bool: + return _dialog_suggestion(callback_id, body) -def _is_valid_event_payload(payload: dict) -> bool: - return ( - payload - and _is_expected_type(payload, "event_callback") - and "event" in payload - and "type" in payload["event"] - ) + return build_listener_matcher(func, asyncio, base_logger) -def _is_expected_type(payload: dict, expected: str) -> bool: - return payload and "type" in payload and payload["type"] == expected +# ------------------------- def _matches(str_or_pattern: Union[str, Pattern], input: Optional[str]) -> bool: @@ -378,8 +536,6 @@ def _matches(str_or_pattern: Union[str, Pattern], input: Optional[str]) -> bool: return input == exact_match_str elif isinstance(str_or_pattern, Pattern): pattern: Pattern = str_or_pattern - return pattern.search(input) + return pattern.search(input) is not None else: - raise BoltError( - f"{str_or_pattern} ({type(str_or_pattern)}) must be either str or Pattern" - ) + raise BoltError(f"{str_or_pattern} ({type(str_or_pattern)}) must be either str or Pattern") diff --git a/slack_bolt/listener_matcher/custom_listener_matcher.py b/slack_bolt/listener_matcher/custom_listener_matcher.py index 693dc6bb8..cbc062b13 100644 --- a/slack_bolt/listener_matcher/custom_listener_matcher.py +++ b/slack_bolt/listener_matcher/custom_listener_matcher.py @@ -1,25 +1,25 @@ -import inspect from logging import Logger -from typing import Callable, List +from typing import Callable, MutableSequence, Optional from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.logger import get_bolt_app_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from .listener_matcher import ListenerMatcher +from ..util.utils import get_arg_names_of_callable class CustomListenerMatcher(ListenerMatcher): app_name: str func: Callable[..., bool] - arg_names: List[str] + arg_names: MutableSequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., bool]): + def __init__(self, *, app_name: str, func: Callable[..., bool], base_logger: Optional[Logger] = None): self.app_name = app_name self.func = func - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.arg_names = get_arg_names_of_callable(func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: return self.func( @@ -28,5 +28,6 @@ def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: required_arg_names=self.arg_names, request=req, response=resp, + this_func=self.func, ) ) diff --git a/slack_bolt/listener_matcher/listener_matcher.py b/slack_bolt/listener_matcher/listener_matcher.py index 57a027d06..2393fa506 100644 --- a/slack_bolt/listener_matcher/listener_matcher.py +++ b/slack_bolt/listener_matcher/listener_matcher.py @@ -7,4 +7,13 @@ class ListenerMatcher(metaclass=ABCMeta): @abstractmethod def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: + """Matches against the request and returns True if matched. + + Args: + req: The request + resp: The response + + Returns: + True if matched. + """ raise NotImplementedError() diff --git a/slack_bolt/logger/__init__.py b/slack_bolt/logger/__init__.py index e219dbb36..12dbab225 100644 --- a/slack_bolt/logger/__init__.py +++ b/slack_bolt/logger/__init__.py @@ -1,23 +1,48 @@ +"""Bolt for Python relies on the standard `logging` module.""" + import logging from logging import Logger -from typing import Any +from typing import Any, Optional -def get_bolt_logger(cls: Any) -> Logger: +def get_bolt_logger(cls: Any, base_logger: Optional[Logger] = None) -> Logger: logger = logging.getLogger(f"slack_bolt.{cls.__name__}") - logger.disabled = logging.root.disabled - logger.level = logging.root.level + if base_logger is not None: + _configure_from_base_logger(logger, base_logger) + else: + _configure_from_root(logger) return logger -def get_bolt_app_logger(app_name: str, cls: object = None) -> Logger: - if cls and hasattr(cls, "__name__"): - logger = logging.getLogger(f"{app_name}:{cls.__name__}") - logger.disabled = logging.root.disabled - logger.level = logging.root.level - return logger +def get_bolt_app_logger(app_name: str, cls: object = None, base_logger: Optional[Logger] = None) -> Logger: + logger: Logger = ( + logging.getLogger(f"{app_name}:{cls.__name__}") if cls and hasattr(cls, "__name__") else logging.getLogger(app_name) + ) + + if base_logger is not None: + _configure_from_base_logger(logger, base_logger) else: - logger = logging.getLogger(app_name) - logger.disabled = logging.root.disabled - logger.level = logging.root.level - return logger + _configure_from_root(logger) + return logger + + +def _configure_from_base_logger(new_logger: Logger, base_logger: Logger): + new_logger.disabled = base_logger.disabled + new_logger.level = base_logger.level + if len(new_logger.handlers) == 0: + for h in base_logger.handlers: + new_logger.addHandler(h) + if len(new_logger.filters) == 0: + for f in base_logger.filters: + new_logger.addFilter(f) + + +def _configure_from_root(new_logger: Logger): + new_logger.disabled = logging.root.disabled + new_logger.level = logging.root.level + + +__all__ = [ + "get_bolt_logger", + "get_bolt_app_logger", +] diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py new file mode 100644 index 000000000..80e68d022 --- /dev/null +++ b/slack_bolt/logger/messages.py @@ -0,0 +1,385 @@ +from re import Pattern +import time +from typing import Union, Dict, Any, Optional + +from slack_sdk.web import SlackResponse + +from slack_bolt.request import BoltRequest +from slack_bolt.request.payload_utils import ( + is_action, + is_event, + is_function, + is_options, + is_shortcut, + is_slash_command, + is_view_submission, + is_view_closed, + is_workflow_step_edit, + is_workflow_step_save, + is_workflow_step_execute, +) + +# ------------------------------- +# Error +# ------------------------------- + + +def error_client_invalid_type() -> str: + return "`client` must be a slack_sdk.web.WebClient" + + +def error_client_invalid_type_async() -> str: + return "`client` must be a slack_sdk.web.async_client.AsyncWebClient" + + +def error_oauth_flow_invalid_type_async() -> str: + return "`oauth_flow` must be a slack_bolt.oauth.async_oauth_flow.AsyncOAuthFlow" + + +def error_oauth_settings_invalid_type_async() -> str: + return "`oauth_settings` must be a slack_bolt.oauth.async_oauth_settings.AsyncOAuthSettings" + + +def error_auth_test_failure(error_response: SlackResponse) -> str: + return f"`token` is invalid (auth.test result: {error_response})" + + +def error_token_required() -> str: + return "Either an env variable `SLACK_BOT_TOKEN` " "or `token` argument in the constructor is required." + + +def error_unexpected_listener_middleware(middleware_type) -> str: + return f"Unexpected value for a listener middleware: {middleware_type}" + + +def error_listener_function_must_be_coro_func(func_name: str) -> str: + return f"The listener function ({func_name}) is not a coroutine function." + + +def error_authorize_conflicts() -> str: + return "`authorize` in the top-level arguments is not allowed when you pass either `oauth_settings` or `oauth_flow`" + + +def error_message_event_type(event_type: Union[str, Pattern]) -> str: + return ( + f'Although the document mentions "{event_type}", ' + 'it is not a valid event type. Use "message" instead. ' + "If you want to filter message events, you can use `event.channel_type` for it." + ) + + +def error_installation_store_required_for_builtin_listeners() -> str: + return ( + "To use the event listeners for token revocation handling, " + "setting a valid `installation_store` to `App`/`AsyncApp` is required." + ) + + +def error_oauth_flow_or_authorize_required() -> str: + return "`oauth_flow` or `authorize` must be configured to make a Bolt app" + + +# ------------------------------- +# Warning +# ------------------------------- + + +def warning_client_prioritized_and_token_skipped() -> str: + return "As you gave `client` as well, `token` will be unused." + + +def warning_token_skipped() -> str: + return ( + "As `installation_store` or `authorize` has been used, " "`token` (or SLACK_BOT_TOKEN env variable) will be ignored." + ) + + +def warning_installation_store_conflicts() -> str: + return "As you gave both `installation_store` and `oauth_settings`/`auth_flow`, the top level one is unused." + + +def warning_unhandled_by_global_middleware( + name: str, req: Union[BoltRequest, "AsyncBoltRequest"] # type: ignore[name-defined] +) -> str: + return ( + f"A global middleware ({name}) skipped calling either `next()` or `next_()` " + f"without providing a response for the request ({req.body})" + ) + + +_unhandled_request_suggestion_prefix = """ +--- +[Suggestion] You can handle this type of event with the following listener function: +""" + + +def _build_filtered_body(body: Optional[Dict[str, Any]]) -> dict: + if body is None: + return {} + + payload_type = body.get("type") + filtered_body = {"type": payload_type} + + if "view" in body: + view = body["view"] + # view_submission, view_closed, workflow_step_save + filtered_body["view"] = { + "type": view.get("type"), + "callback_id": view.get("callback_id"), + } + + if payload_type == "block_actions": + # Block Kit Interactivity + actions = body.get("actions", []) + if len(actions) > 0 and actions[0] is not None: + filtered_body["block_id"] = actions[0].get("block_id") + filtered_body["action_id"] = actions[0].get("action_id") + if payload_type == "block_suggestion": + # Block Kit - external data source + filtered_body["block_id"] = body.get("block_id") + filtered_body["action_id"] = body.get("action_id") + filtered_body["value"] = body.get("value") + + if payload_type == "event_callback" and "event" in body: + # Events API, workflow_step_execute + event_payload = body.get("event", {}) + filtered_event = {"type": event_payload.get("type")} + if "subtype" in body["event"]: + filtered_event["subtype"] = event_payload.get("subtype") + filtered_body["event"] = filtered_event + + if "command" in body: + # Slash Commands + filtered_body["command"] = body.get("command") + + if payload_type in ["workflow_step_edit", "shortcut", "message_action"]: + # Steps from apps, Global Shortcuts, Message Shortcuts + filtered_body["callback_id"] = body.get("callback_id") + + if payload_type == "interactive_message": + # Actions in Attachments + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["actions"] = body.get("actions") + + if payload_type == "dialog_suggestion": + # Dialogs - external data source + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["value"] = body.get("value") + if payload_type == "dialog_submission": + # Dialogs - clicking submit button + filtered_body["callback_id"] = body.get("callback_id") + filtered_body["submission"] = body.get("submission") + if payload_type == "dialog_cancellation": + # Dialogs - clicking cancel button + filtered_body["callback_id"] = body.get("callback_id") + + return filtered_body + + +def _build_unhandled_request_suggestion(default_message: str, code_snippet: str): + return f"""{default_message}{_unhandled_request_suggestion_prefix}{code_snippet}""" + + +def warning_unhandled_request( + req: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore[name-defined] +) -> str: + filtered_body = _build_filtered_body(req.body) + default_message = f"Unhandled request ({filtered_body})" + is_async = not isinstance(req, BoltRequest) + if is_workflow_step_edit(req.body) or is_workflow_step_save(req.body) or is_workflow_step_execute(req.body): + # @app.step + callback_id = ( + filtered_body.get("callback_id") or filtered_body.get("view", {}).get("callback_id") or "your-callback-id" + ) + return _build_unhandled_request_suggestion( + default_message, + f""" +from slack_bolt.workflows.step{'.async_step' if is_async else ''} import {'Async' if is_async else ''}WorkflowStep +ws = {'Async' if is_async else ''}WorkflowStep( + callback_id="{callback_id}", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""", + ) + if is_action(req.body): + # @app.action + action_id_or_callback_id = req.body.get("callback_id") + if req.body.get("type") == "block_actions": + action_id_or_callback_id = req.body["actions"][0].get("action_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.action("{action_id_or_callback_id}") +{'async ' if is_async else ''}def handle_some_action(ack, body, logger): + {'await ' if is_async else ''}ack() + logger.info(body) +""", + ) + if is_options(req.body): + # @app.options + constraints = '"action-id"' + if req.body.get("action_id") is not None: + constraints = '"' + req.body["action_id"] + '"' + elif req.body.get("type") == "dialog_suggestion": + constraints = f"""{{"type": "dialog_suggestion", "callback_id": "{req.body.get('callback_id')}"}}""" + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.options({constraints}) +{'async ' if is_async else ''}def handle_some_options(ack): + {'await ' if is_async else ''}ack(options=[ ... ]) +""", + ) + if is_shortcut(req.body): + # @app.shortcut + id = req.body.get("action_id") or req.body.get("callback_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.shortcut("{id}") +{'async ' if is_async else ''}def handle_shortcuts(ack, body, logger): + {'await ' if is_async else ''}ack() + logger.info(body) +""", + ) + if is_view_submission(req.body): + # @app.view + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.view("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}") +{'async ' if is_async else ''}def handle_view_submission_events(ack, body, logger): + {'await ' if is_async else ''}ack() + logger.info(body) +""", + ) + if is_view_closed(req.body): + # @app.view + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.view_closed("{req.body.get('view', {}).get('callback_id', 'modal-view-id')}") +{'async ' if is_async else ''}def handle_view_closed_events(ack, body, logger): + {'await ' if is_async else ''}ack() + logger.info(body) +""", + ) + if is_event(req.body): + # @app.event + event = req.body.get("event", {}) + event_type = event.get("type") + if is_function(req.body): + # @app.function + callback_id = event.get("function", {}).get("callback_id", "function_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.function("{callback_id}") +{'async ' if is_async else ''}def handle_some_function(ack, body, complete, fail, logger): + {'await ' if is_async else ''}ack() + logger.info(body) + try: + # TODO: do something here + outputs = {{}} + {'await ' if is_async else ''}complete(outputs=outputs) + except Exception as e: + error = f"Failed to handle a function request (error: {{e}})" + {'await ' if is_async else ''}fail(error=error) +""", + ) + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.event("{event_type}") +{'async ' if is_async else ''}def handle_{event_type}_events(body, logger): + logger.info(body) +""", + ) + if is_slash_command(req.body): + # @app.command + command = req.body.get("command", "/your-command") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.command("{command}") +{'async ' if is_async else ''}def handle_some_command(ack, body, logger): + {'await ' if is_async else ''}ack() + logger.info(body) +""", + ) + return default_message + + +def warning_did_not_call_ack(listener_name: str) -> str: + return f"{listener_name} didn't call ack()" + + +def warning_bot_only_conflicts() -> str: + return ( + "installation_store_bot_only exists in both App and OAuthFlow.settings. " + "The one passed in App constructor is used." + ) + + +def warning_skip_uncommon_arg_name(arg_name: str) -> str: + return ( + f"Bolt skips injecting a value to the first keyword argument ({arg_name}). " + "If it is self/cls of a method, we recommend using the common names." + ) + + +def warning_ack_timeout_has_no_effect(identifier: Union[str, Pattern], ack_timeout: int) -> str: + handler_example = f'@app.function("{identifier}")' if isinstance(identifier, str) else f"@app.function({identifier})" + return f"On {handler_example}, as `auto_acknowledge` is `True`, " f"`ack_timeout={ack_timeout}` you gave will be unused" + + +# ------------------------------- +# Info +# ------------------------------- + + +def info_default_oauth_settings_loaded() -> str: + return ( + "As you've set SLACK_CLIENT_ID and SLACK_CLIENT_SECRET env variables, " + "Bolt has enabled the file-based InstallationStore/OAuthStateStore for you. " + "Note that these file-based stores are for local development. " + "If you'd like to use a different data store, set the oauth_settings argument in the App constructor. " + "Please refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for more details." + ) + + +# ------------------------------- +# Debug +# ------------------------------- + + +def debug_applying_middleware(middleware_name: str) -> str: + return f"Applying {middleware_name}" + + +def debug_checking_listener(listener_name: str) -> str: + return f"Checking listener: {listener_name} ..." + + +def debug_running_listener(listener_name: str) -> str: + return f"Running listener: {listener_name} ..." + + +def debug_running_lazy_listener(func_name: str) -> str: + return f"Running lazy listener: {func_name} ..." + + +def debug_responding(status: int, body: str, millis: int) -> str: + return f'Responding with status: {status} body: "{body}" ({millis} millis)' + + +def debug_return_listener_middleware_response(listener_name: str, status: int, body: str, starting_time: float) -> str: + millis = int((time.time() - starting_time) * 1000) + return ( + "Responding with listener middleware's response - " + f"listener: {listener_name}, status: {status}, body: {body} ({millis} millis)" + ) diff --git a/slack_bolt/middleware/__init__.py b/slack_bolt/middleware/__init__.py index d6dc530fe..c28ffd78d 100644 --- a/slack_bolt/middleware/__init__.py +++ b/slack_bolt/middleware/__init__.py @@ -1,11 +1,23 @@ +"""A middleware processes request data and calls `next()` method +if the execution chain should continue running the following middleware. + +Middleware can be used globally before all listener executions. +It's also possible to run a middleware only for a particular listener. +""" + # Don't add async module imports here -from .authorization import SingleTeamAuthorization, MultiTeamsAuthorization +from .authorization import ( + SingleTeamAuthorization, + MultiTeamsAuthorization, +) from .custom_middleware import CustomMiddleware from .ignoring_self_events import IgnoringSelfEvents from .middleware import Middleware from .request_verification import RequestVerification from .ssl_check import SslCheck from .url_verification import UrlVerification +from .attaching_function_token import AttachingFunctionToken +from .attaching_conversation_kwargs import AttachingConversationKwargs builtin_middleware_classes = [ SslCheck, @@ -14,6 +26,22 @@ MultiTeamsAuthorization, IgnoringSelfEvents, UrlVerification, + AttachingFunctionToken, + # Assistant, # to avoid circular imports ] for cls in builtin_middleware_classes: - Middleware.register(cls) + Middleware.register(cls) # type: ignore[arg-type] + +__all__ = [ + "SingleTeamAuthorization", + "MultiTeamsAuthorization", + "CustomMiddleware", + "IgnoringSelfEvents", + "Middleware", + "RequestVerification", + "SslCheck", + "UrlVerification", + "AttachingFunctionToken", + "AttachingConversationKwargs", + "builtin_middleware_classes", +] diff --git a/slack_bolt/middleware/assistant/__init__.py b/slack_bolt/middleware/assistant/__init__.py new file mode 100644 index 000000000..4487394ab --- /dev/null +++ b/slack_bolt/middleware/assistant/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .assistant import Assistant + +__all__ = [ + "Assistant", +] diff --git a/slack_bolt/middleware/assistant/assistant.py b/slack_bolt/middleware/assistant/assistant.py new file mode 100644 index 000000000..ad842f94d --- /dev/null +++ b/slack_bolt/middleware/assistant/assistant.py @@ -0,0 +1,302 @@ +import logging +from functools import wraps +from logging import Logger +from typing import List, Optional, Union, Callable + +from slack_bolt.context.save_thread_context import SaveThreadContext +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_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 +from slack_bolt.error import BoltError +from slack_bolt.listener.custom_listener import CustomListener +from slack_bolt.listener import Listener +from slack_bolt.listener.thread_runner import ThreadListenerRunner +from slack_bolt.middleware import Middleware +from slack_bolt.listener_matcher import ListenerMatcher +from slack_bolt.request.payload_utils import ( + is_assistant_thread_started_event, + is_user_message_event_in_assistant_thread, + is_assistant_thread_context_changed_event, + is_other_message_sub_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, +) +from slack_bolt.util.utils import is_used_without_argument + + +class Assistant(Middleware): + _thread_started_listeners: Optional[List[Listener]] + _thread_context_changed_listeners: Optional[List[Listener]] + _user_message_listeners: Optional[List[Listener]] + _bot_message_listeners: Optional[List[Listener]] + + thread_context_store: Optional[AssistantThreadContextStore] + base_logger: Optional[logging.Logger] + + def __init__( + self, + *, + app_name: str = "assistant", + thread_context_store: Optional[AssistantThreadContextStore] = None, + logger: Optional[logging.Logger] = None, + ): + self.app_name = app_name + self.thread_context_store = thread_context_store + self.base_logger = logger + + self._thread_started_listeners = None + self._thread_context_changed_listeners = None + self._user_message_listeners = None + self._bot_message_listeners = None + + def thread_started( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_started_listeners is None: + self._thread_started_listeners = [] + all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers) + if is_used_without_argument(args): + func = args[0] + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def user_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._user_message_listeners is None: + self._user_message_listeners = [] + all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers) + if is_used_without_argument(args): + func = args[0] + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def bot_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._bot_message_listeners is None: + self._bot_message_listeners = [] + all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers) + if is_used_without_argument(args): + func = args[0] + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def thread_context_changed( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_context_changed_listeners is None: + self._thread_context_changed_listeners = [] + all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers) + if is_used_without_argument(args): + func = args[0] + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def _merge_matchers( + self, + primary_matcher: Callable[..., bool], + custom_matchers: Optional[Union[Callable[..., bool], ListenerMatcher]], + ): + return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + ( + custom_matchers or [] + ) # 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] + self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse] + ) -> Optional[BoltResponse]: + if self._thread_context_changed_listeners is None: + self.thread_context_changed(self.default_thread_context_changed) + + listener_runner: ThreadListenerRunner = req.context.listener_runner + for listeners in [ + self._thread_started_listeners, + self._thread_context_changed_listeners, + self._user_message_listeners, + self._bot_message_listeners, + ]: + 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, + listener_name="assistant_listener", + listener=listener, + ) + if is_other_message_sub_event_in_assistant_thread(req.body): + # message_changed, message_deleted, etc. + return req.context.ack() + + next() + + def build_listener( + self, + listener_or_functions: Union[Listener, Callable, List[Callable]], + matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None, + 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, 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) + + matchers = matchers if matchers else [] + listener_matchers: List[ListenerMatcher] = [] + for matcher in matchers: + if isinstance(matcher, ListenerMatcher): + listener_matchers.append(matcher) + elif isinstance(matcher, Callable): # type: ignore[arg-type] + listener_matchers.append( + build_listener_matcher( + func=matcher, + asyncio=False, + base_logger=base_logger, + ) + ) + return CustomListener( + app_name=self.app_name, + matchers=listener_matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, + auto_acknowledgement=True, + base_logger=base_logger or self.base_logger, + ) + else: + raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected") diff --git a/slack_bolt/middleware/assistant/async_assistant.py b/slack_bolt/middleware/assistant/async_assistant.py new file mode 100644 index 000000000..588de8b41 --- /dev/null +++ b/slack_bolt/middleware/assistant/async_assistant.py @@ -0,0 +1,333 @@ +import logging +from functools import wraps +from logging import Logger +from typing import List, Optional, Union, Callable, Awaitable + +from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext +from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore + +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.listener_matcher.builtins import build_listener_matcher +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 +from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener +from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher +from slack_bolt.request.payload_utils import ( + is_assistant_thread_started_event, + is_user_message_event_in_assistant_thread, + is_assistant_thread_context_changed_event, + is_other_message_sub_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, +) +from slack_bolt.util.utils import is_used_without_argument + + +class AsyncAssistant(AsyncMiddleware): + _thread_started_listeners: Optional[List[AsyncListener]] + _user_message_listeners: Optional[List[AsyncListener]] + _bot_message_listeners: Optional[List[AsyncListener]] + _thread_context_changed_listeners: Optional[List[AsyncListener]] + + thread_context_store: Optional[AsyncAssistantThreadContextStore] + base_logger: Optional[logging.Logger] + + def __init__( + self, + *, + app_name: str = "assistant", + thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + logger: Optional[logging.Logger] = None, + ): + self.app_name = app_name + self.thread_context_store = thread_context_store + self.base_logger = logger + + self._thread_started_listeners = None + self._thread_context_changed_listeners = None + self._user_message_listeners = None + self._bot_message_listeners = None + + def thread_started( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_started_listeners is None: + self._thread_started_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_assistant_thread_started_event, + asyncio=True, + base_logger=self.base_logger, + ), # type: ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def user_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._user_message_listeners is None: + self._user_message_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_user_message_event_in_assistant_thread, + asyncio=True, + base_logger=self.base_logger, + ), # type: ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def bot_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._bot_message_listeners is None: + self._bot_message_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_bot_message_event_in_assistant_thread, + asyncio=True, + base_logger=self.base_logger, + ), # type: ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def thread_context_changed( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_context_changed_listeners is None: + self._thread_context_changed_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_assistant_thread_context_changed_event, + asyncio=True, + base_logger=self.base_logger, + ), # type: ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type: ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + @staticmethod + def _merge_matchers( + primary_matcher: Union[Callable[..., bool], AsyncListenerMatcher], + custom_matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]], + ): + return [primary_matcher] + (custom_matchers or []) # type: ignore[operator] + + @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] + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + if self._thread_context_changed_listeners is None: + self.thread_context_changed(self.default_thread_context_changed) + + listener_runner: AsyncioListenerRunner = req.context.listener_runner + for listeners in [ + self._thread_started_listeners, + self._thread_context_changed_listeners, + self._user_message_listeners, + self._bot_message_listeners, + ]: + 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, + listener_name="assistant_listener", + listener=listener, + ) + if is_other_message_sub_event_in_assistant_thread(req.body): + # message_changed, message_deleted, etc. + return await req.context.ack() + + await next() + + def build_listener( + self, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], + matchers: Optional[List[Union[AsyncListenerMatcher, Callable[..., Awaitable[bool]]]]] = None, + 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, 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) + + matchers = matchers if matchers else [] + listener_matchers: List[AsyncListenerMatcher] = [] + for matcher in matchers: + if isinstance(matcher, AsyncListenerMatcher): + listener_matchers.append(matcher) + else: + listener_matchers.append( + build_listener_matcher( + func=matcher, # type: ignore[arg-type] + asyncio=True, + base_logger=base_logger, + ) + ) + return AsyncCustomListener( + app_name=self.app_name, + matchers=listener_matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, + auto_acknowledgement=True, + base_logger=base_logger or self.base_logger, + ) + else: + raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected") diff --git a/slack_bolt/middleware/async_builtins.py b/slack_bolt/middleware/async_builtins.py index 3b9536f0e..8de07fb88 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -1,7 +1,23 @@ -from .async_ignoring_self_events import AsyncIgnoringSelfEvents # noqa -from .async_request_verification import AsyncRequestVerification # noqa -from .async_ssl_check import AsyncSslCheck # noqa -from .async_url_verification import AsyncUrlVerification # noqa -from .authorization.async_single_team_authorization import ( - AsyncSingleTeamAuthorization, -) # noqa +from .ignoring_self_events.async_ignoring_self_events import ( + AsyncIgnoringSelfEvents, +) +from .request_verification.async_request_verification import ( + AsyncRequestVerification, +) +from .ssl_check.async_ssl_check import AsyncSslCheck +from .url_verification.async_url_verification import AsyncUrlVerification +from .message_listener_matches.async_message_listener_matches import ( + AsyncMessageListenerMatches, +) +from .attaching_function_token.async_attaching_function_token import AsyncAttachingFunctionToken +from .attaching_conversation_kwargs.async_attaching_conversation_kwargs import AsyncAttachingConversationKwargs + +__all__ = [ + "AsyncIgnoringSelfEvents", + "AsyncRequestVerification", + "AsyncSslCheck", + "AsyncUrlVerification", + "AsyncMessageListenerMatches", + "AsyncAttachingFunctionToken", + "AsyncAttachingConversationKwargs", +] diff --git a/slack_bolt/middleware/async_custom_middleware.py b/slack_bolt/middleware/async_custom_middleware.py index 26bcf48a9..18856a3b2 100644 --- a/slack_bolt/middleware/async_custom_middleware.py +++ b/slack_bolt/middleware/async_custom_middleware.py @@ -1,35 +1,44 @@ -import inspect from logging import Logger -from typing import Callable, Awaitable, List, Any +from typing import Callable, Awaitable, Any, MutableSequence, Optional from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.logger import get_bolt_app_logger from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_middleware import AsyncMiddleware +from slack_bolt.util.utils import get_name_for_callable, get_arg_names_of_callable, is_callable_coroutine class AsyncCustomMiddleware(AsyncMiddleware): app_name: str func: Callable[..., Awaitable[Any]] - arg_names: List[str] + arg_names: MutableSequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable[..., Awaitable[Any]]): + def __init__( + self, + *, + app_name: str, + func: Callable[..., Awaitable[Any]], + base_logger: Optional[Logger] = None, + ): self.app_name = app_name - if inspect.iscoroutinefunction(func): + if is_callable_coroutine(func): self.func = func else: raise ValueError("Async middleware function must be an async function") - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.arg_names = get_arg_names_of_callable(func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) async def async_process( self, *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: return await self.func( @@ -38,10 +47,11 @@ async def async_process( required_arg_names=self.arg_names, request=req, response=resp, - next_func=next, + next_func=next, # type: ignore[arg-type] + this_func=self.func, ) ) @property def name(self) -> str: - return f"AsyncCustomMiddleware(func={self.func.__name__})" + return f"AsyncCustomMiddleware(func={get_name_for_callable(self.func)})" diff --git a/slack_bolt/middleware/async_ignoring_self_events.py b/slack_bolt/middleware/async_ignoring_self_events.py deleted file mode 100644 index 220ba4f6e..000000000 --- a/slack_bolt/middleware/async_ignoring_self_events.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Callable, Awaitable - -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.response import BoltResponse -from . import IgnoringSelfEvents -from .async_middleware import AsyncMiddleware - - -class AsyncIgnoringSelfEvents(IgnoringSelfEvents, AsyncMiddleware): - async def async_process( - self, - *, - req: AsyncBoltRequest, - resp: BoltResponse, - next: Callable[[], Awaitable[BoltResponse]], - ) -> BoltResponse: - auth_result = req.context.authorization_result - if self._is_self_event(auth_result, req.context.user_id): - self._debug_log(req.payload) - return await req.context.ack() - else: - return await next() diff --git a/slack_bolt/middleware/async_middleware.py b/slack_bolt/middleware/async_middleware.py index 1e846f684..163def40a 100644 --- a/slack_bolt/middleware/async_middleware.py +++ b/slack_bolt/middleware/async_middleware.py @@ -1,21 +1,51 @@ from abc import ABCMeta, abstractmethod -from typing import Callable, Awaitable +from typing import Callable, Awaitable, Optional from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse class AsyncMiddleware(metaclass=ABCMeta): + """A middleware can process request data before other middleware and listener functions.""" + @abstractmethod async def async_process( self, *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], - ) -> BoltResponse: + ) -> Optional[BoltResponse]: + """Processes a request data before other middleware and listeners. + A middleware calls `next()` function if the chain should continue. + + @app.middleware + async def simple_middleware(req, resp, next): + # do something here + await next() + + This `async_process(req, resp, next)` method is supposed to be invoked only inside bolt-python. + If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead. + + @app.middleware + async def simple_middleware(req, resp, next_): + # do something here + await next_() + + Args: + req: The incoming request + resp: The response + next: The function to tell the chain that it can continue + + Returns: + Processed response (optional) + """ raise NotImplementedError() @property def name(self) -> str: + """The name of this middleware""" return f"{self.__module__}.{self.__class__.__name__}" diff --git a/slack_bolt/middleware/async_middleware_error_handler.py b/slack_bolt/middleware/async_middleware_error_handler.py new file mode 100644 index 000000000..932b0770b --- /dev/null +++ b/slack_bolt/middleware/async_middleware_error_handler.py @@ -0,0 +1,68 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Dict, Any, Awaitable, Optional + +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class AsyncMiddlewareErrorHandler(metaclass=ABCMeta): + @abstractmethod + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + """Handles an unhandled exception. + + Args: + error: The raised exception. + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class AsyncCustomMiddlewareErrorHandler(AsyncMiddlewareErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ) -> None: + kwargs: Dict[str, Any] = build_async_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + error=error, + request=request, + response=response, + next_keys_required=False, + ) + returned_response = await self.func(**kwargs) + if returned_response is not None and isinstance(returned_response, BoltResponse): + 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): + def __init__(self, logger: Logger): + self.logger = logger + + async def handle( + self, + error: Exception, + request: AsyncBoltRequest, + response: Optional[BoltResponse], + ): + message = f"Failed to run a middleware function (error: {error})" + self.logger.exception(message) 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/attaching_function_token/__init__.py b/slack_bolt/middleware/attaching_function_token/__init__.py new file mode 100644 index 000000000..5531cc897 --- /dev/null +++ b/slack_bolt/middleware/attaching_function_token/__init__.py @@ -0,0 +1,5 @@ +from .attaching_function_token import AttachingFunctionToken + +__all__ = [ + "AttachingFunctionToken", +] diff --git a/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.py b/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.py new file mode 100644 index 000000000..434133e7b --- /dev/null +++ b/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.py @@ -0,0 +1,20 @@ +from typing import Callable, Awaitable + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.middleware.async_middleware import AsyncMiddleware + + +class AsyncAttachingFunctionToken(AsyncMiddleware): + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + # This method is not supposed to be invoked by bolt-python users + next: Callable[[], Awaitable[BoltResponse]], + ) -> BoltResponse: + if req.context.function_bot_access_token is not None: + req.context.client.token = req.context.function_bot_access_token + + return await next() diff --git a/slack_bolt/middleware/attaching_function_token/attaching_function_token.py b/slack_bolt/middleware/attaching_function_token/attaching_function_token.py new file mode 100644 index 000000000..aea4a77a1 --- /dev/null +++ b/slack_bolt/middleware/attaching_function_token/attaching_function_token.py @@ -0,0 +1,20 @@ +from typing import Callable + +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.middleware.middleware import Middleware + + +class AttachingFunctionToken(Middleware): + def process( + self, + *, + req: BoltRequest, + resp: BoltResponse, + # This method is not supposed to be invoked by bolt-python users + next: Callable[[], BoltResponse], + ) -> BoltResponse: + if req.context.function_bot_access_token is not None: + req.context.client.token = req.context.function_bot_access_token + + return next() diff --git a/slack_bolt/middleware/authorization/__init__.py b/slack_bolt/middleware/authorization/__init__.py index 832947b45..714868274 100644 --- a/slack_bolt/middleware/authorization/__init__.py +++ b/slack_bolt/middleware/authorization/__init__.py @@ -2,3 +2,9 @@ from .authorization import Authorization from .multi_teams_authorization import MultiTeamsAuthorization from .single_team_authorization import SingleTeamAuthorization + +__all__ = [ + "Authorization", + "MultiTeamsAuthorization", + "SingleTeamAuthorization", +] diff --git a/slack_bolt/middleware/authorization/async_internals.py b/slack_bolt/middleware/authorization/async_internals.py index 9d5cc394a..b5d8264ca 100644 --- a/slack_bolt/middleware/authorization/async_internals.py +++ b/slack_bolt/middleware/authorization/async_internals.py @@ -3,27 +3,20 @@ def _is_url_verification(req: AsyncBoltRequest) -> bool: - return ( - req is not None - and req.payload is not None - and req.payload.get("type", None) == "url_verification" - ) + return req is not None and req.body is not None and req.body.get("type") == "url_verification" def _is_ssl_check(req: AsyncBoltRequest) -> bool: - return ( - req is not None - and req.payload is not None - and req.payload.get("type", None) == "ssl_check" - ) + return req is not None and req.body is not None and req.body.get("type") == "ssl_check" def _is_no_auth_required(req: AsyncBoltRequest) -> bool: return _is_url_verification(req) or _is_ssl_check(req) -def _build_error_response() -> BoltResponse: +def _build_user_facing_error_response(message: str) -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( - status=200, body=":x: Please install this app into the workspace :bow:", + status=200, + body=message, ) diff --git a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py index 3fe6d7286..592431f0f 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -1,80 +1,106 @@ +from logging import Logger from typing import Callable, Optional, Awaitable from slack_sdk.errors import SlackApiError -from slack_sdk.oauth.installation_store import Bot -from slack_sdk.oauth.installation_store.async_installation_store import ( - AsyncInstallationStore, -) - -from slack_bolt.auth.result import AuthorizationResult from slack_bolt.logger import get_bolt_logger from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from .async_authorization import AsyncAuthorization -from .async_internals import _build_error_response, _is_no_auth_required -from ...util.async_utils import create_async_web_client +from .async_internals import _build_user_facing_error_response, _is_no_auth_required +from .internals import _is_no_auth_test_call_required, _build_user_facing_authorize_error_message +from ...authorization import AuthorizeResult +from ...authorization.async_authorize import AsyncAuthorize class AsyncMultiTeamsAuthorization(AsyncAuthorization): - installation_store: AsyncInstallationStore - verification_enabled: bool + authorize: AsyncAuthorize + user_token_resolution: str def __init__( self, - installation_store: AsyncInstallationStore, - verification_enabled: bool = True, + authorize: AsyncAuthorize, + base_logger: Optional[Logger] = None, + user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): - self.installation_store = installation_store - self.verification_enabled = verification_enabled - self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization) + """Multi-workspace authorization. + + Args: + authorize: The function to authorize incoming requests from Slack. + base_logger: The base logger + user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found + """ + self.authorize = authorize + self.logger = get_bolt_logger(AsyncMultiTeamsAuthorization, base_logger=base_logger) + self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) async def async_process( self, *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if _is_no_auth_required(req): return await next() - try: - bot: Optional[Bot] = await self.installation_store.async_find_bot( - enterprise_id=req.context.enterprise_id, team_id=req.context.team_id, + + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) ) - if bot is None: - return _build_error_response() + return await next() - if self.verification_enabled: - auth_result = await req.context.client.auth_test(token=bot.bot_token) - if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=bot.bot_token, - ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_async_web_client(bot.bot_token) - return await next() - else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() + try: + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) + else: + auth_result = await self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) + if auth_result: + req.context.set_authorize_result(auth_result) + token = auth_result.bot_token or auth_result.user_token + req.context["token"] = token + # As AsyncApp#_init_context() generates a new AsyncWebClient for this request, + # it's safe to modify this instance. + req.context.client.token = token + return await next() else: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=bot.enterprise_id, - team_id=bot.team_id, - bot_user_id=bot.bot_user_id, - bot_id=bot.bot_id, - bot_token=bot.bot_token, + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_async_web_client(bot.bot_token) - return next() + if req.context.response_url is not None: + await req.context.respond(self.user_facing_authorize_error_message) # type: ignore[misc] + return BoltResponse(status=200, body="") + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/slack_bolt/middleware/authorization/async_single_team_authorization.py b/slack_bolt/middleware/authorization/async_single_team_authorization.py index 3f3ad395a..c783ce4ce 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -1,43 +1,73 @@ -from typing import Callable, Awaitable +from logging import Logger +from typing import Callable, Awaitable, Optional -from slack_bolt.auth import AuthorizationResult from slack_bolt.logger import get_bolt_logger from slack_bolt.middleware.authorization.async_authorization import AsyncAuthorization from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError -from .async_internals import _build_error_response, _is_no_auth_required +from .async_internals import _build_user_facing_error_response, _is_no_auth_required +from .internals import _to_authorize_result, _is_no_auth_test_call_required, _build_user_facing_authorize_error_message +from ...authorization import AuthorizeResult class AsyncSingleTeamAuthorization(AsyncAuthorization): - def __init__(self): - self.logger = get_bolt_logger(AsyncSingleTeamAuthorization) + def __init__( + self, + base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, + ): + """Single-workspace authorization.""" + self.auth_test_result: Optional[AsyncSlackResponse] = None + self.logger = get_bolt_logger(AsyncSingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) async def async_process( self, *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: if _is_no_auth_required(req): return await next() - try: - auth_result = await req.context.client.auth_test() - if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=req.context.client.token, + + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, user_id=req.context.user_id, ) + ) + return await next() + + try: + if self.auth_test_result is None: + self.auth_test_result = await req.context.client.auth_test() + + if self.auth_test_result: + req.context.set_authorize_result( + _to_authorize_result( + auth_test_result=self.auth_test_result, + token=req.context.client.token, + request_user_id=req.context.user_id, + ) + ) return await next() else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() + if req.context.response_url is not None: + await req.context.respond(self.user_facing_authorize_error_message) # type: ignore[misc] + return BoltResponse(status=200, body="") + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/slack_bolt/middleware/authorization/internals.py b/slack_bolt/middleware/authorization/internals.py index 06ea7e9a8..85116963c 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -1,29 +1,82 @@ +from typing import Optional, Union + +from slack_sdk.web import SlackResponse + +from slack_bolt.authorization import AuthorizeResult from slack_bolt.request.request import BoltRequest from slack_bolt.response import BoltResponse +# +# NOTE: this source file intentionally avoids having a reference to +# AsyncBoltRequest, AsyncSlackResponse, and whatever Async-prefixed. +# +# The reason why we do so is to enable developers use sync version of Bolt +# without installing aiohttp library (or any others we may use for async things) +# -def _is_url_verification(req: BoltRequest) -> bool: - return ( - req is not None - and req.payload is not None - and req.payload.get("type", None) == "url_verification" - ) + +def _is_url_verification(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore[name-defined] + return req is not None and req.body is not None and req.body.get("type") == "url_verification" -def _is_ssl_check(req: BoltRequest) -> bool: +def _is_ssl_check(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore[name-defined] + return req is not None and req.body is not None and req.body.get("type") == "ssl_check" + + +no_auth_test_events = ["app_uninstalled", "tokens_revoked", "team_access_revoked"] + + +def _is_no_auth_test_events(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore[name-defined] return ( req is not None - and req.payload is not None - and req.payload.get("type", None) == "ssl_check" + and req.body is not None + and req.body.get("type") == "event_callback" + and req.body.get("event", {}).get("type") in no_auth_test_events ) -def _is_no_auth_required(req: BoltRequest) -> bool: +def _is_no_auth_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore[name-defined] return _is_url_verification(req) or _is_ssl_check(req) -def _build_error_response() -> BoltResponse: +def _is_no_auth_test_call_required(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore[name-defined] + return _is_no_auth_test_events(req) + + +def _build_user_facing_authorize_error_message() -> str: + return ( + ":warning: We apologize, but for some unknown reason, your installation with this app is no longer available. " + "Please reinstall this app into your workspace :bow:" + ) + + +def _build_user_facing_error_response(message: str) -> BoltResponse: # show an ephemeral message to the end-user return BoltResponse( - status=200, body=":x: Please install this app into the workspace :bow:", + status=200, + body=message, + ) + + +def _is_bot_token(token: Optional[str]) -> bool: + return token is not None and token.startswith("xoxb-") + + +def _to_authorize_result( + auth_test_result: Union[SlackResponse, "AsyncSlackResponse"], # type: ignore[name-defined] + token: Optional[str], + request_user_id: Optional[str], +) -> AuthorizeResult: + user_id = auth_test_result.get("user_id") + oauth_scopes: Optional[str] = auth_test_result.headers.get("x-oauth-scopes") + return AuthorizeResult( + enterprise_id=auth_test_result.get("enterprise_id"), + team_id=auth_test_result.get("team_id"), + bot_id=auth_test_result.get("bot_id"), + bot_user_id=user_id if _is_bot_token(token) else None, + bot_token=token if _is_bot_token(token) else None, + user_id=request_user_id or (user_id if not _is_bot_token(token) else None), + user_token=token if not _is_bot_token(token) else None, + bot_scopes=oauth_scopes if _is_bot_token(token) else None, + user_scopes=None if _is_bot_token(token) else oauth_scopes, ) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index cdbe4425f..ee8896ea3 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -1,70 +1,109 @@ +from logging import Logger from typing import Callable, Optional -from slack_bolt.auth.result import AuthorizationResult +from slack_sdk.errors import SlackApiError + from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_sdk.errors import SlackApiError -from slack_sdk.oauth.installation_store import InstallationStore, Bot -from . import Authorization -from .internals import _build_error_response, _is_no_auth_required -from ...util.utils import create_web_client +from .authorization import Authorization +from .internals import ( + _build_user_facing_error_response, + _is_no_auth_required, + _is_no_auth_test_call_required, + _build_user_facing_authorize_error_message, +) +from ...authorization import AuthorizeResult +from ...authorization.authorize import Authorize class MultiTeamsAuthorization(Authorization): - installation_store: InstallationStore - verification_enabled: bool + authorize: Authorize + user_token_resolution: str def __init__( - self, installation_store: InstallationStore, verification_enabled: bool = True, + self, + *, + authorize: Authorize, + base_logger: Optional[Logger] = None, + user_token_resolution: str = "authed_user", + user_facing_authorize_error_message: Optional[str] = None, ): - self.installation_store = installation_store - self.verification_enabled = verification_enabled - self.logger = get_bolt_logger(MultiTeamsAuthorization) + """Multi-workspace authorization. + + Args: + authorize: The function to authorize incoming requests from Slack. + base_logger: The base logger + user_token_resolution: "authed_user" or "actor" + user_facing_authorize_error_message: The user-facing error message when installation is not found + """ + self.authorize = authorize + self.logger = get_bolt_logger(MultiTeamsAuthorization, base_logger=base_logger) + self.user_token_resolution = user_token_resolution + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: if _is_no_auth_required(req): return next() - try: - bot: Optional[Bot] = self.installation_store.find_bot( - enterprise_id=req.context.enterprise_id, team_id=req.context.team_id, + + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + ) ) - if bot is None: - return _build_error_response() + return next() - if self.verification_enabled: - auth_result = req.context.client.auth_test(token=bot.bot_token) - if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=bot.bot_token, - ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_web_client(bot.bot_token) - return next() - else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() + try: + auth_result: Optional[AuthorizeResult] = None + if self.user_token_resolution == "actor": + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, + actor_enterprise_id=req.context.actor_enterprise_id, + actor_team_id=req.context.actor_team_id, + actor_user_id=req.context.actor_user_id, + ) else: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=bot.enterprise_id, - team_id=bot.team_id, - bot_user_id=bot.bot_user_id, - bot_id=bot.bot_id, - bot_token=bot.bot_token, + auth_result = self.authorize( + context=req.context, + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, + user_id=req.context.user_id, ) - # TODO: bot -> user token - req.context["token"] = bot.bot_token - req.context["client"] = create_web_client(bot.bot_token) + if auth_result is not None: + req.context.set_authorize_result(auth_result) + token = auth_result.bot_token or auth_result.user_token + req.context["token"] = token + # As App#_init_context() generates a new WebClient for this request, + # it's safe to modify this instance. + req.context.client.token = token return next() + else: + # This situation can arise if: + # * A developer installed the app from the "Install to Workspace" button in Slack app config page + # * The InstallationStore failed to save or deleted the installation for this workspace + self.logger.error( + "Although the app should be installed into this workspace, " + "the AuthorizeResult (returned value from authorize) for it was not found." + ) + if req.context.response_url is not None: + req.context.respond(self.user_facing_authorize_error_message) # type: ignore[misc] + return BoltResponse(status=200, body="") + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index c22e2117a..c2bc1488c 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -1,39 +1,86 @@ -from typing import Callable +from logging import Logger +from typing import Callable, Optional -from slack_bolt.auth import AuthorizationResult from slack_bolt.logger import get_bolt_logger -from slack_bolt.middleware.authorization import Authorization +from .authorization import Authorization from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError -from .internals import _build_error_response, _is_no_auth_required +from slack_sdk.web import SlackResponse +from .internals import ( + _build_user_facing_error_response, + _is_no_auth_required, + _to_authorize_result, + _is_no_auth_test_call_required, + _build_user_facing_authorize_error_message, +) +from ...authorization import AuthorizeResult class SingleTeamAuthorization(Authorization): - def __init__(self): - self.logger = get_bolt_logger(SingleTeamAuthorization) + def __init__( + self, + *, + auth_test_result: Optional[SlackResponse] = None, + base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, + ): + """Single-workspace authorization. + + Args: + auth_test_result: The initial `auth.test` API call result. + base_logger: The base logger + """ + self.auth_test_result = auth_test_result + self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) + self.user_facing_authorize_error_message = ( + user_facing_authorize_error_message or _build_user_facing_authorize_error_message() + ) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], ) -> BoltResponse: + if _is_no_auth_required(req): return next() - try: - auth_result = req.context.client.auth_test() - if auth_result: - req.context["authorization_result"] = AuthorizationResult( - enterprise_id=auth_result.get("enterprise_id", None), - team_id=auth_result.get("team_id", None), - bot_user_id=auth_result.get("user_id", None), - bot_id=auth_result.get("bot_id", None), - bot_token=req.context.client.token, + + if _is_no_auth_test_call_required(req): + req.context.set_authorize_result( + AuthorizeResult( + enterprise_id=req.context.enterprise_id, + team_id=req.context.team_id, user_id=req.context.user_id, ) + ) + return next() + + try: + if not self.auth_test_result: + self.auth_test_result = req.context.client.auth_test() + + if self.auth_test_result: + req.context.set_authorize_result( + _to_authorize_result( + auth_test_result=self.auth_test_result, + token=req.context.client.token, + request_user_id=req.context.user_id, + ) + ) return next() else: # Just in case self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() + if req.context.response_url is not None: + req.context.respond(self.user_facing_authorize_error_message) # type: ignore[misc] + return BoltResponse(status=200, body="") + return _build_user_facing_error_response(self.user_facing_authorize_error_message) except SlackApiError as e: self.logger.error(f"Failed to authorize with the given token ({e})") - return _build_error_response() + return _build_user_facing_error_response(self.user_facing_authorize_error_message) diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index d8c6f82e0..49b390a2a 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -1,28 +1,35 @@ -import inspect from logging import Logger -from typing import Callable, List, Any +from typing import Callable, Any, MutableSequence, Optional from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.logger import get_bolt_app_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from .middleware import Middleware +from slack_bolt.util.utils import get_name_for_callable, get_arg_names_of_callable class CustomMiddleware(Middleware): app_name: str func: Callable[..., Any] - arg_names: List[str] + arg_names: MutableSequence[str] logger: Logger - def __init__(self, *, app_name: str, func: Callable): + def __init__(self, *, app_name: str, func: Callable, base_logger: Optional[Logger] = None): self.app_name = app_name self.func = func - self.arg_names = inspect.getfullargspec(func).args - self.logger = get_bolt_app_logger(self.app_name, self.func) + self.arg_names = get_arg_names_of_callable(func) + self.logger = get_bolt_app_logger(self.app_name, self.func, base_logger) def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], + self, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], ) -> BoltResponse: return self.func( **build_required_kwargs( @@ -30,10 +37,11 @@ def process( required_arg_names=self.arg_names, request=req, response=resp, - next_func=next, + next_func=next, # type: ignore[arg-type] + this_func=self.func, ) ) @property def name(self) -> str: - return f"CustomMiddleware(func={self.func.__name__})" + return f"CustomMiddleware(func={get_name_for_callable(self.func)})" diff --git a/slack_bolt/middleware/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events.py deleted file mode 100644 index 5252a1876..000000000 --- a/slack_bolt/middleware/ignoring_self_events.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging -from typing import Callable - -from slack_bolt.auth import AuthorizationResult -from slack_bolt.logger import get_bolt_logger -from slack_bolt.request import BoltRequest -from slack_bolt.response import BoltResponse -from .middleware import Middleware - - -class IgnoringSelfEvents(Middleware): - def __init__(self): - self.logger = get_bolt_logger(IgnoringSelfEvents) - - def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], - ) -> BoltResponse: - auth_result = req.context.authorization_result - if self._is_self_event(auth_result, req.context.user_id): - self._debug_log(req.payload) - return req.context.ack() - else: - return next() - - # ----------------------------------------- - - @staticmethod - def _is_self_event(auth_result: AuthorizationResult, user_id: str): - return auth_result is not None and user_id == auth_result.bot_user_id - - def _debug_log(self, payload: dict): - if self.logger.level <= logging.DEBUG: - event = payload["event"] - self.logger.debug(f"Skipped self event: {event}") diff --git a/slack_bolt/middleware/ignoring_self_events/__init__.py b/slack_bolt/middleware/ignoring_self_events/__init__.py new file mode 100644 index 000000000..1212e6468 --- /dev/null +++ b/slack_bolt/middleware/ignoring_self_events/__init__.py @@ -0,0 +1,5 @@ +from .ignoring_self_events import IgnoringSelfEvents + +__all__ = [ + "IgnoringSelfEvents", +] diff --git a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py new file mode 100644 index 000000000..11a3f40ee --- /dev/null +++ b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py @@ -0,0 +1,30 @@ +from typing import Callable, Awaitable + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from .ignoring_self_events import IgnoringSelfEvents +from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.request.payload_utils import is_bot_message_event_in_assistant_thread + + +class AsyncIgnoringSelfEvents(IgnoringSelfEvents, AsyncMiddleware): + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> BoltResponse: + auth_result = req.context.authorize_result + # message events can have $.event.bot_id while it does not have its user_id + bot_id = req.body.get("event", {}).get("bot_id") + if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body): # type: ignore[arg-type] + if self.ignoring_self_assistant_message_events_enabled is False: + if is_bot_message_event_in_assistant_thread(req.body): + # Assistant#bot_message handler acknowledges this pattern + return await next() + + self._debug_log(req.body) + return await req.context.ack() + else: + return await next() diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py new file mode 100644 index 000000000..3380636f0 --- /dev/null +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -0,0 +1,71 @@ +import logging +from typing import Callable, Dict, Any, Optional + +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.logger import get_bolt_logger +from slack_bolt.request import BoltRequest +from slack_bolt.request.payload_utils import is_bot_message_event_in_assistant_thread +from slack_bolt.response import BoltResponse +from slack_bolt.middleware.middleware import Middleware + + +class IgnoringSelfEvents(Middleware): + def __init__( + self, + base_logger: Optional[logging.Logger] = None, + ignoring_self_assistant_message_events_enabled: bool = True, + ): + """Ignores the events generated by this bot user itself.""" + self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger) + self.ignoring_self_assistant_message_events_enabled = ignoring_self_assistant_message_events_enabled + + def process( + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], + ) -> BoltResponse: + auth_result = req.context.authorize_result + # message events can have $.event.bot_id while it does not have its user_id + bot_id = req.body.get("event", {}).get("bot_id") + if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body): # type: ignore[arg-type] + if self.ignoring_self_assistant_message_events_enabled is False: + if is_bot_message_event_in_assistant_thread(req.body): + # Assistant#bot_message handler acknowledges this pattern + return next() + + self._debug_log(req.body) + return req.context.ack() + else: + return next() + + # ----------------------------------------- + + # It's an Events API event that isn't of type message, + # but the user ID might match our own app. Filter these out. + # However, some events still must be fired, because they can make sense. + events_that_should_be_kept = ["member_joined_channel", "member_left_channel"] + + @classmethod + def _is_self_event( + cls, + auth_result: AuthorizeResult, + user_id: Optional[str], + bot_id: Optional[str], + body: Dict[str, Any], + ): + return ( + auth_result is not None + and ( + (user_id is not None and user_id == auth_result.bot_user_id) + or (bot_id is not None and bot_id == auth_result.bot_id) # for bot_message events + ) + and body.get("event") is not None + and body.get("event", {}).get("type") not in cls.events_that_should_be_kept + ) + + def _debug_log(self, body: dict): + if self.logger.level <= logging.DEBUG: + event = body.get("event") + self.logger.debug(f"Skipped self event: {event}") diff --git a/slack_bolt/middleware/message_listener_matches/__init__.py b/slack_bolt/middleware/message_listener_matches/__init__.py new file mode 100644 index 000000000..090d5679e --- /dev/null +++ b/slack_bolt/middleware/message_listener_matches/__init__.py @@ -0,0 +1,5 @@ +from .message_listener_matches import MessageListenerMatches + +__all__ = [ + "MessageListenerMatches", +] diff --git a/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py new file mode 100644 index 000000000..a3a8c007b --- /dev/null +++ b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py @@ -0,0 +1,36 @@ +import re +from typing import Callable, Awaitable, Optional, Sequence, Union, Pattern + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.middleware.async_middleware import AsyncMiddleware + + +class AsyncMessageListenerMatches(AsyncMiddleware): + def __init__(self, keyword: Union[str, Pattern]): + """Captures matched keywords and saves the values in context.""" + self.keyword = keyword + + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], Awaitable[BoltResponse]], + ) -> BoltResponse: + text = req.body.get("event", {}).get("text", "") + if text: + m: Optional[Union[Sequence]] = re.findall(self.keyword, text) + if m is not None and m != []: + if type(m[0]) is not tuple: + m = tuple(m) + else: + m = m[0] + req.context["matches"] = m # tuple or list + return await next() + + # As the text doesn't match, skip running the listener + return resp diff --git a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py new file mode 100644 index 000000000..e5c59990e --- /dev/null +++ b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py @@ -0,0 +1,36 @@ +import re +from typing import Callable, Optional, Pattern, Sequence, Union + +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.middleware.middleware import Middleware + + +class MessageListenerMatches(Middleware): + def __init__(self, keyword: Union[str, Pattern]): + """Captures matched keywords and saves the values in context.""" + self.keyword = keyword + + def process( + self, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], + ) -> BoltResponse: + text = req.body.get("event", {}).get("text", "") + if text: + m: Optional[Union[Sequence]] = re.findall(self.keyword, text) + if m is not None and m != []: + if type(m[0]) is not tuple: + m = tuple(m) + else: + m = m[0] + req.context["matches"] = m # tuple or list + return next() + + # As the text doesn't match, skip running the listener + return resp diff --git a/slack_bolt/middleware/middleware.py b/slack_bolt/middleware/middleware.py index 8ecc20ce0..560499d6c 100644 --- a/slack_bolt/middleware/middleware.py +++ b/slack_bolt/middleware/middleware.py @@ -1,17 +1,51 @@ from abc import ABCMeta, abstractmethod -from typing import Callable +from typing import Callable, Optional from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse class Middleware(metaclass=ABCMeta): + """A middleware can process request data before other middleware and listener functions.""" + @abstractmethod def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], - ) -> BoltResponse: + self, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], + ) -> Optional[BoltResponse]: + """Processes a request data before other middleware and listeners. + A middleware calls `next()` function if the chain should continue. + + @app.middleware + def simple_middleware(req, resp, next): + # do something here + next() + + This `process(req, resp, next)` method is supposed to be invoked only inside bolt-python. + If you want to avoid the name `next()` in your middleware functions, you can use `next_()` method instead. + + @app.middleware + def simple_middleware(req, resp, next_): + # do something here + next_() + + Args: + req: The incoming request + resp: The response + next: The function to tell the chain that it can continue + + Returns: + Processed response (optional) + """ raise NotImplementedError() @property def name(self) -> str: + """The name of this middleware""" return f"{self.__module__}.{self.__class__.__name__}" diff --git a/slack_bolt/middleware/middleware_error_handler.py b/slack_bolt/middleware/middleware_error_handler.py new file mode 100644 index 000000000..5919414bb --- /dev/null +++ b/slack_bolt/middleware/middleware_error_handler.py @@ -0,0 +1,68 @@ +from abc import ABCMeta, abstractmethod +from logging import Logger +from typing import Callable, Optional, Any, Dict + +from slack_bolt.kwargs_injection.utils import build_required_kwargs +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse +from slack_bolt.util.utils import get_arg_names_of_callable + + +class MiddlewareErrorHandler(metaclass=ABCMeta): + @abstractmethod + def handle( + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], # TODO: why is this optional + ) -> None: + """Handles an unhandled exception. + + Args: + error: The raised exception. + request: The request. + response: The response. + """ + raise NotImplementedError() + + +class CustomMiddlewareErrorHandler(MiddlewareErrorHandler): + def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]): + self.func = func + self.logger = logger + self.arg_names = get_arg_names_of_callable(func) + + def handle( + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], + ): + kwargs: Dict[str, Any] = build_required_kwargs( + required_arg_names=self.arg_names, + logger=self.logger, + error=error, + request=request, + response=response, + next_keys_required=False, + ) + returned_response = self.func(**kwargs) + if returned_response is not None and isinstance(returned_response, BoltResponse): + 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): + def __init__(self, logger: Logger): + self.logger = logger + + def handle( + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], + ): + message = f"Failed to run a middleware (error: {error})" + self.logger.exception(message) diff --git a/slack_bolt/middleware/request_verification.py b/slack_bolt/middleware/request_verification.py deleted file mode 100644 index 6623d4aac..000000000 --- a/slack_bolt/middleware/request_verification.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Callable, Dict, Any - -from slack_sdk.signature import SignatureVerifier - -from slack_bolt.logger import get_bolt_logger -from .middleware import Middleware -from slack_bolt.request import BoltRequest -from slack_bolt.response import BoltResponse - - -class RequestVerification(Middleware): # type: ignore - def __init__(self, signing_secret: str): - self.verifier = SignatureVerifier(signing_secret=signing_secret) - self.logger = get_bolt_logger(RequestVerification) - - def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], - ) -> BoltResponse: - if self._can_skip(req.payload): - return next() - - body = req.body - timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0] - signature = req.headers.get("x-slack-signature", [""])[0] - if self.verifier.is_valid(body, timestamp, signature): - return next() - else: - self._debug_log_error(signature, timestamp, body) - return self._build_error_response() - - # ----------------------------------------- - - @staticmethod - def _can_skip(payload: Dict[str, Any]) -> bool: - return payload is not None and payload.get("ssl_check", None) == "1" - - @staticmethod - def _build_error_response() -> BoltResponse: - return BoltResponse(status=401, body={"error": "invalid request"}) - - def _debug_log_error(self, signature, timestamp, body) -> None: - self.logger.info( - "Invalid request signature detected " - f"(signature: {signature}, timestamp: {timestamp}, body: {body})" - ) diff --git a/slack_bolt/middleware/request_verification/__init__.py b/slack_bolt/middleware/request_verification/__init__.py new file mode 100644 index 000000000..a8c564886 --- /dev/null +++ b/slack_bolt/middleware/request_verification/__init__.py @@ -0,0 +1,5 @@ +from .request_verification import RequestVerification + +__all__ = [ + "RequestVerification", +] diff --git a/slack_bolt/middleware/async_request_verification.py b/slack_bolt/middleware/request_verification/async_request_verification.py similarity index 56% rename from slack_bolt/middleware/async_request_verification.py rename to slack_bolt/middleware/request_verification/async_request_verification.py index 60512cfea..3fb9e209b 100644 --- a/slack_bolt/middleware/async_request_verification.py +++ b/slack_bolt/middleware/request_verification/async_request_verification.py @@ -1,23 +1,32 @@ from typing import Callable, Awaitable -from . import RequestVerification -from .async_middleware import AsyncMiddleware +from slack_bolt.middleware import RequestVerification +from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse class AsyncRequestVerification(RequestVerification, AsyncMiddleware): + """Verifies an incoming request by checking the validity of + `x-slack-signature`, `x-slack-request-timestamp`, and its body data. + + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. + """ + async def async_process( self, *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - if self._can_skip(req.payload): + if self._can_skip(req.mode, req.body): return await next() - body = req.body + body = req.raw_body timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0] signature = req.headers.get("x-slack-signature", [""])[0] if self.verifier.is_valid(body, timestamp, signature): diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py new file mode 100644 index 000000000..af505bc84 --- /dev/null +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -0,0 +1,69 @@ +from logging import Logger +from typing import Any, Callable, Dict, Optional + +from slack_sdk.signature import SignatureVerifier + +from slack_bolt.logger import get_bolt_logger +from slack_bolt.middleware.middleware import Middleware +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class RequestVerification(Middleware): + def __init__(self, signing_secret: str, base_logger: Optional[Logger] = None): + """Verifies an incoming request by checking the validity of + `x-slack-signature`, `x-slack-request-timestamp`, and its body data. + + Refer to https://docs.slack.dev/authentication/verifying-requests-from-slack/ for details. + + Args: + signing_secret: The signing secret + base_logger: The base logger + """ + 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, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], + ) -> BoltResponse: + if self._can_skip(req.mode, req.body): + return next() + + body = req.raw_body + timestamp = req.headers.get("x-slack-request-timestamp", ["0"])[0] + signature = req.headers.get("x-slack-signature", [""])[0] + if self.verifier.is_valid(body, timestamp, signature): + return next() + else: + self._debug_log_error(signature, timestamp, body) + return self._build_error_response() + + # ----------------------------------------- + + @staticmethod + def _can_skip(mode: str, body: Dict[str, Any]) -> bool: + return mode == "socket_mode" + + @staticmethod + def _build_error_response() -> BoltResponse: + return BoltResponse(status=401, body={"error": "invalid request"}) + + def _debug_log_error(self, signature, timestamp, body) -> None: + self.logger.info( + "Invalid request signature detected " f"(signature: {signature}, timestamp: {timestamp}, body: {body})" + ) diff --git a/slack_bolt/middleware/ssl_check.py b/slack_bolt/middleware/ssl_check.py deleted file mode 100644 index 5e80cf446..000000000 --- a/slack_bolt/middleware/ssl_check.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Callable - -from slack_bolt.logger import get_bolt_logger -from .middleware import Middleware -from slack_bolt.request import BoltRequest -from slack_bolt.response import BoltResponse - - -class SslCheck(Middleware): # type: ignore - def __init__(self, verification_token: str = None): - self.verification_token = verification_token - self.logger = get_bolt_logger(SslCheck) - - def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], - ) -> BoltResponse: - if self._is_ssl_check_request(req.payload): - if self._verify_token_if_needed(req.payload): - return self._build_error_response() - return self._build_success_response() - else: - return next() - - # ----------------------------------------- - - @staticmethod - def _is_ssl_check_request(payload: dict): - return "ssl_check" in payload and payload["ssl_check"] == "1" - - def _verify_token_if_needed(self, payload: dict): - return self.verification_token and self.verification_token == payload["token"] - - @staticmethod - def _build_success_response() -> BoltResponse: - return BoltResponse(status=200, body="") - - @staticmethod - def _build_error_response() -> BoltResponse: - return BoltResponse(status=401, body={"error": "invalid verification token"}) diff --git a/slack_bolt/middleware/ssl_check/__init__.py b/slack_bolt/middleware/ssl_check/__init__.py new file mode 100644 index 000000000..33a3708d5 --- /dev/null +++ b/slack_bolt/middleware/ssl_check/__init__.py @@ -0,0 +1,5 @@ +from .ssl_check import SslCheck + +__all__ = [ + "SslCheck", +] diff --git a/slack_bolt/middleware/async_ssl_check.py b/slack_bolt/middleware/ssl_check/async_ssl_check.py similarity index 58% rename from slack_bolt/middleware/async_ssl_check.py rename to slack_bolt/middleware/ssl_check/async_ssl_check.py index 141a13bdf..5b806a4bc 100644 --- a/slack_bolt/middleware/async_ssl_check.py +++ b/slack_bolt/middleware/ssl_check/async_ssl_check.py @@ -1,7 +1,7 @@ from typing import Callable, Awaitable -from . import SslCheck -from .async_middleware import AsyncMiddleware +from .ssl_check import SslCheck +from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -12,10 +12,13 @@ async def async_process( *, req: AsyncBoltRequest, resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - if self._is_ssl_check_request(req.payload): - if self._verify_token_if_needed(req.payload): + if self._is_ssl_check_request(req.body): + if self._verify_token_if_needed(req.body): return self._build_error_response() return self._build_success_response() else: diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py new file mode 100644 index 000000000..88c5105ef --- /dev/null +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -0,0 +1,62 @@ +from logging import Logger +from typing import Callable, Optional + +from slack_bolt.logger import get_bolt_logger +from slack_bolt.middleware.middleware import Middleware +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class SslCheck(Middleware): + verification_token: Optional[str] + logger: Logger + + def __init__( + self, + verification_token: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): + """Handles `ssl_check` requests. + Refer to https://docs.slack.dev/interactivity/implementing-slash-commands/ for details. + + Args: + verification_token: The verification token to check + (optional as it's already deprecated - https://docs.slack.dev/authentication/verifying-requests-from-slack/#deprecation) + base_logger: The base logger + """ # noqa: E501 + self.verification_token = verification_token + self.logger = get_bolt_logger(SslCheck, base_logger=base_logger) + + def process( + self, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], + ) -> BoltResponse: + if self._is_ssl_check_request(req.body): + if self._verify_token_if_needed(req.body): + return self._build_error_response() + return self._build_success_response() + else: + return next() + + # ----------------------------------------- + + @staticmethod + def _is_ssl_check_request(body: dict): + return "ssl_check" in body and body["ssl_check"] == "1" + + def _verify_token_if_needed(self, body: dict): + return self.verification_token and self.verification_token == body["token"] + + @staticmethod + def _build_success_response() -> BoltResponse: + return BoltResponse(status=200, body="") + + @staticmethod + def _build_error_response() -> BoltResponse: + return BoltResponse(status=401, body={"error": "invalid verification token"}) diff --git a/slack_bolt/middleware/url_verification.py b/slack_bolt/middleware/url_verification.py deleted file mode 100644 index 01366179b..000000000 --- a/slack_bolt/middleware/url_verification.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Callable - -from slack_bolt.logger import get_bolt_logger -from .middleware import Middleware -from slack_bolt.request import BoltRequest -from slack_bolt.response import BoltResponse - - -class UrlVerification(Middleware): # type: ignore - def __init__(self): - self.logger = get_bolt_logger(UrlVerification) - - def process( - self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse], - ) -> BoltResponse: - if self._is_url_verification_request(req.payload): - return self._build_success_response(req.payload) - else: - return next() - - # ----------------------------------------- - - @staticmethod - def _is_url_verification_request(payload: dict) -> bool: - return payload is not None and payload.get("type", None) == "url_verification" - - @staticmethod - def _build_success_response(payload: dict) -> BoltResponse: - return BoltResponse(status=200, body={"challenge": payload.get("challenge")}) diff --git a/slack_bolt/middleware/url_verification/__init__.py b/slack_bolt/middleware/url_verification/__init__.py new file mode 100644 index 000000000..4fc29dfce --- /dev/null +++ b/slack_bolt/middleware/url_verification/__init__.py @@ -0,0 +1,5 @@ +from .url_verification import UrlVerification + +__all__ = [ + "UrlVerification", +] diff --git a/slack_bolt/middleware/async_url_verification.py b/slack_bolt/middleware/url_verification/async_url_verification.py similarity index 50% rename from slack_bolt/middleware/async_url_verification.py rename to slack_bolt/middleware/url_verification/async_url_verification.py index 9bbbf2ec9..2371bc3a4 100644 --- a/slack_bolt/middleware/async_url_verification.py +++ b/slack_bolt/middleware/url_verification/async_url_verification.py @@ -1,15 +1,16 @@ -from typing import Callable, Awaitable +from logging import Logger +from typing import Callable, Awaitable, Optional from slack_bolt.logger import get_bolt_logger -from . import UrlVerification -from .async_middleware import AsyncMiddleware +from .url_verification import UrlVerification +from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse class AsyncUrlVerification(UrlVerification, AsyncMiddleware): - def __init__(self): - self.logger = get_bolt_logger(AsyncUrlVerification) + def __init__(self, base_logger: Optional[Logger] = None): + self.logger = get_bolt_logger(AsyncUrlVerification, base_logger=base_logger) async def async_process( self, @@ -18,7 +19,7 @@ async def async_process( resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: - if self._is_url_verification_request(req.payload): - return self._build_success_response(req.payload) + if self._is_url_verification_request(req.body): + return self._build_success_response(req.body) else: return await next() diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py new file mode 100644 index 000000000..7505c9c15 --- /dev/null +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -0,0 +1,44 @@ +from logging import Logger +from typing import Callable, Optional + +from slack_bolt.logger import get_bolt_logger +from slack_bolt.middleware.middleware import Middleware +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class UrlVerification(Middleware): + def __init__(self, base_logger: Optional[Logger] = None): + """Handles url_verification requests. + + Refer to https://docs.slack.dev/reference/events/url_verification/ for details. + + Args: + base_logger: The base logger + """ + self.logger = get_bolt_logger(UrlVerification, base_logger=base_logger) + + def process( + self, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], + ) -> BoltResponse: + if self._is_url_verification_request(req.body): + return self._build_success_response(req.body) + else: + return next() + + # ----------------------------------------- + + @staticmethod + def _is_url_verification_request(body: dict) -> bool: + return body is not None and body.get("type") == "url_verification" + + @staticmethod + def _build_success_response(body: dict) -> BoltResponse: + return BoltResponse(status=200, body={"challenge": body.get("challenge")}) diff --git a/slack_bolt/oauth/__init__.py b/slack_bolt/oauth/__init__.py index 533df1c5c..0a5c3db07 100644 --- a/slack_bolt/oauth/__init__.py +++ b/slack_bolt/oauth/__init__.py @@ -1,2 +1,11 @@ +"""Slack OAuth flow support for building an app that is installable in any workspaces. + +Refer to https://docs.slack.dev/tools/bolt-python/concepts/authenticating-oauth for details. +""" + # Don't add async module imports here -from .oauth_flow import OAuthFlow # noqa +from .oauth_flow import OAuthFlow + +__all__ = [ + "OAuthFlow", +] diff --git a/slack_bolt/oauth/async_callback_options.py b/slack_bolt/oauth/async_callback_options.py new file mode 100644 index 000000000..e1c2b2e4c --- /dev/null +++ b/slack_bolt/oauth/async_callback_options.py @@ -0,0 +1,115 @@ +import logging +from logging import Logger +from typing import Optional, Callable, Awaitable, TYPE_CHECKING + +from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils +from slack_sdk.oauth.installation_store import Installation +from slack_bolt.oauth.internals import CallbackResponseBuilder + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + +if TYPE_CHECKING: + from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings + + +class AsyncSuccessArgs: + def __init__( + self, + *, + request: AsyncBoltRequest, + installation: Installation, + settings: "AsyncOAuthSettings", + default: "AsyncCallbackOptions", + ): + """The arguments for a success function. + + Args: + request: The request. + installation: The installation data. + settings: The settings for Slack OAuth flow. + default: The default `AsyncCallbackOptions`. + """ + self.request = request + self.installation = installation + self.settings = settings + self.default = default + + +class AsyncFailureArgs: + def __init__( + self, + *, + request: AsyncBoltRequest, + reason: str, + error: Optional[Exception] = None, + suggested_status_code: int, + settings: "AsyncOAuthSettings", + default: "AsyncCallbackOptions", + ): + """The arguments for a failure function. + + Args: + request: The request. + reason: The response. + error: An exception if exists. + suggested_status_code: The recommended HTTP status code for the failure. + settings: The settings for Slack OAuth flow. + default: The default `AsyncCallbackOptions`. + """ + self.request = request + self.reason = reason + self.error = error + self.suggested_status_code = suggested_status_code + self.settings = settings + self.default = default + + +class AsyncCallbackOptions: + success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]] + failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]] + + def __init__( + self, + success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]], + failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]], + ): + self.success = success + self.failure = failure + + +class DefaultAsyncCallbackOptions(AsyncCallbackOptions): + success: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]] + failure: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]] + + def __init__( + self, + *, + logger: Logger, + state_utils: OAuthStateUtils, + redirect_uri_page_renderer: RedirectUriPageRenderer, + ): + self._response_builder = CallbackResponseBuilder( + logger=logger or logging.getLogger(__name__), + state_utils=state_utils, + redirect_uri_page_renderer=redirect_uri_page_renderer, + ) + self.success = self._success_handler + self.failure = self._failure_handler + + # -------------------------- + # Internal methods + # -------------------------- + + async def _success_handler(self, args: AsyncSuccessArgs) -> BoltResponse: + return self._response_builder._build_callback_success_response( + request=args.request, + installation=args.installation, + ) + + async def _failure_handler(self, args: AsyncFailureArgs) -> BoltResponse: + return self._response_builder._build_callback_failure_response( + request=args.request, + reason=args.reason, + status=args.suggested_status_code, + ) diff --git a/slack_bolt/oauth/async_internals.py b/slack_bolt/oauth/async_internals.py new file mode 100644 index 000000000..cc2327a81 --- /dev/null +++ b/slack_bolt/oauth/async_internals.py @@ -0,0 +1,45 @@ +from logging import Logger +from typing import Dict, Optional + +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) + +from ..logger.messages import warning_installation_store_conflicts + +# key: client_id, value: AsyncInstallationStore +default_installation_stores: Dict[str, AsyncInstallationStore] = {} + + +def get_or_create_default_installation_store(client_id: str) -> AsyncInstallationStore: + store = default_installation_stores.get(client_id) + if store is None: + store = FileInstallationStore(client_id=client_id) + default_installation_stores[client_id] = store + return store + + +def select_consistent_installation_store( + client_id: str, + app_store: Optional[AsyncInstallationStore], + oauth_flow_store: Optional[AsyncInstallationStore], + logger: Logger, +) -> Optional[AsyncInstallationStore]: + default = get_or_create_default_installation_store(client_id) + if app_store is not None: + if oauth_flow_store is not None: + if oauth_flow_store is default: + # only app_store is intentionally set in this case + return app_store + + # if both are intentionally set, prioritize app_store + if oauth_flow_store is not app_store: + logger.warning(warning_installation_store_conflicts()) + return oauth_flow_store + else: + # only app_store is available + return app_store + else: + # only oauth_flow_store is available + return oauth_flow_store diff --git a/slack_bolt/oauth/async_oauth_flow.py b/slack_bolt/oauth/async_oauth_flow.py index 034b6b029..e7f0fa724 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -1,23 +1,24 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict +from typing import Optional, Dict, Callable, Awaitable, Sequence from slack_bolt.error import BoltError -from slack_bolt.request import BoltRequest +from slack_bolt.logger.messages import error_oauth_settings_invalid_type_async +from slack_bolt.oauth.async_callback_options import ( + AsyncCallbackOptions, + DefaultAsyncCallbackOptions, + AsyncSuccessArgs, + AsyncFailureArgs, +) +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.oauth.internals import _build_default_install_page_html +from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError -from slack_sdk.oauth import ( - AuthorizeUrlGenerator, - OAuthStateUtils, - RedirectUriPageRenderer, -) +from slack_sdk.oauth import OAuthStateUtils from slack_sdk.oauth.installation_store import Installation -from slack_sdk.oauth.installation_store.async_installation_store import ( - AsyncInstallationStore, -) from slack_sdk.oauth.installation_store.sqlite3 import SQLite3InstallationStore -from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore from slack_sdk.oauth.state_store.sqlite3 import SQLite3OAuthStateStore from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse @@ -26,95 +27,65 @@ class AsyncOAuthFlow: - installation_store: AsyncInstallationStore - oauth_state_store: AsyncOAuthStateStore - oauth_state_cookie_name: str - oauth_state_expiration_seconds: int - + settings: AsyncOAuthSettings client_id: str - client_secret: str redirect_uri: Optional[str] - scopes: Optional[List[str]] - user_scopes: Optional[List[str]] - install_path: str redirect_uri_path: str - success_url: Optional[str] - failure_url: Optional[str] - oauth_state_utils: OAuthStateUtils - authorize_url_generator: AuthorizeUrlGenerator - redirect_uri_page_renderer: RedirectUriPageRenderer - @property - def client(self) -> AsyncWebClient: - if self._async_client is None: - self._async_client = create_async_web_client() - return self._async_client - - @property - def logger(self) -> Logger: - if self._logger is None: - self._logger = logging.getLogger(__name__) - return self._logger + success_handler: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]] + failure_handler: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]] def __init__( self, *, client: Optional[AsyncWebClient] = None, logger: Optional[Logger] = None, - installation_store: AsyncInstallationStore, - oauth_state_store: AsyncOAuthStateStore, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, - client_id: str, - client_secret: str, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - install_path: str = "/slack/install", - redirect_uri_path: str = "/slack/oauth_redirect", - success_url: Optional[str] = None, - failure_url: Optional[str] = None, + settings: AsyncOAuthSettings, ): + """The module to run the Slack app installation flow (OAuth flow). + + Args: + client: The `slack_sdk.web.async_client.AsyncWebClient` instance. + logger: The logger. + settings: OAuth settings to configure this module. + """ self._async_client = client self._logger = logger - self.installation_store = installation_store - self.oauth_state_store = oauth_state_store - self.oauth_state_cookie_name = oauth_state_cookie_name - self.oauth_state_expiration_seconds = oauth_state_expiration_seconds + if not isinstance(settings, AsyncOAuthSettings): + raise BoltError(error_oauth_settings_invalid_type_async()) + self.settings = settings - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.scopes = scopes - self.user_scopes = user_scopes + if self._logger is not None: + self.settings.logger = self._logger - self.install_path = install_path - self.redirect_uri_path = redirect_uri_path - self.success_url = success_url - self.failure_url = failure_url + self.client_id = self.settings.client_id + self.redirect_uri = self.settings.redirect_uri + self.install_path = self.settings.install_path + self.redirect_uri_path = self.settings.redirect_uri_path - self._init_internal_utils() - - def _init_internal_utils(self): - self.oauth_state_utils = OAuthStateUtils( - cookie_name=self.oauth_state_cookie_name, - expiration_seconds=self.oauth_state_expiration_seconds, - ) - self.authorize_url_generator = AuthorizeUrlGenerator( - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, - scopes=self.scopes, - user_scopes=self.user_scopes, - ) - self.redirect_uri_page_renderer = RedirectUriPageRenderer( - install_path=self.install_path, - redirect_uri_path=self.redirect_uri_path, - success_url=self.success_url, - failure_url=self.failure_url, + self.default_callback_options = DefaultAsyncCallbackOptions( + logger=logger, # type: ignore[arg-type] + state_utils=self.settings.state_utils, + redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, ) + if settings.callback_options is None: + settings.callback_options = self.default_callback_options + self.success_handler = settings.callback_options.success + self.failure_handler = settings.callback_options.failure + + @property + def client(self) -> AsyncWebClient: + if self._async_client is None: + self._async_client = create_async_web_client(logger=self.logger) + return self._async_client + + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger # ----------------------------- # Factory Methods @@ -124,112 +95,217 @@ def _init_internal_utils(self): def sqlite3( cls, database: str, - client_id: Optional[str] = os.environ.get("SLACK_CLIENT_ID", None), - client_secret: Optional[str] = os.environ.get("SLACK_CLIENT_SECRET", None), - scopes: List[str] = os.environ.get("SLACK_SCOPES", "").split(","), - user_scopes: List[str] = os.environ.get("SLACK_USER_SCOPES", "").split(","), - redirect_uri: Optional[str] = os.environ.get("SLACK_REDIRECT_URI", None), - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # OAuth flow parameters/credentials + authorization_url: Optional[str] = None, + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, + redirect_uri: Optional[str] = None, + # Handler configuration + install_path: Optional[str] = None, + redirect_uri_path: Optional[str] = None, + callback_options: Optional[AsyncCallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + # Installation Management + # state parameter related configurations + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + installation_store_bot_only: bool = False, + client: Optional[AsyncWebClient] = None, logger: Optional[Logger] = None, ) -> "AsyncOAuthFlow": + client_id = client_id or os.environ["SLACK_CLIENT_ID"] # required + client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required + scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") + redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") + installation_store = ( + SQLite3InstallationStore(database=database, client_id=client_id) + if logger is None + else SQLite3InstallationStore(database=database, client_id=client_id, logger=logger) + ) + state_store = ( + SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds) + if logger is None + else SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds, logger=logger) + ) return AsyncOAuthFlow( - client=create_async_web_client(), + client=client or AsyncWebClient(), logger=logger, - installation_store=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, + settings=AsyncOAuthSettings( + # OAuth flow parameters/credentials + authorization_url=authorization_url, + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + user_scopes=user_scopes, + redirect_uri=redirect_uri, + # Handler configuration + install_path=install_path, # type: ignore[arg-type] + redirect_uri_path=redirect_uri_path, # type: ignore[arg-type] + callback_options=callback_options, + success_url=success_url, + failure_url=failure_url, + # Installation Management + installation_store=installation_store, + installation_store_bot_only=installation_store_bot_only, + # state parameter related configurations + state_store=state_store, + state_cookie_name=state_cookie_name, + state_expiration_seconds=state_expiration_seconds, ), - oauth_state_store=SQLite3OAuthStateStore( - database=database, - expiration_seconds=oauth_state_expiration_seconds, - logger=logger, - ), - oauth_state_cookie_name=oauth_state_cookie_name, - oauth_state_expiration_seconds=oauth_state_expiration_seconds, - client_id=client_id, - client_secret=client_secret, - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, ) # ----------------------------- # Installation # ----------------------------- - async def handle_installation(self, request: BoltRequest) -> BoltResponse: - state = await self.issue_new_state(request) - return await self.build_authorize_url_redirection(request, state) + async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: + set_cookie_value: Optional[str] = None + url = await self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = await self.issue_new_state(request) + url = await self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state) + if self.settings.install_page_rendering_enabled: + html = await self.build_install_page_html(url, request) + return BoltResponse( + status=200, + body=html, + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), + ) + else: + return BoltResponse( + status=302, + body="", + headers=await self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), + ) # ---------------------- # Internal methods for Installation - async def issue_new_state(self, request: BoltRequest) -> str: - return await self.oauth_state_store.async_issue() - - async def build_authorize_url_redirection( - self, request: BoltRequest, state: str - ) -> BoltResponse: - return BoltResponse( - status=302, - headers={ - "Location": [self.authorize_url_generator.generate(state)], - "Set-Cookie": [ - self.oauth_state_utils.build_set_cookie_for_new_state(state) - ], - }, + async def issue_new_state(self, request: AsyncBoltRequest) -> str: + return await self.settings.state_store.async_issue() + + async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str: + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, ) + async def build_install_page_html(self, url: str, request: AsyncBoltRequest) -> str: + return _build_default_install_page_html(url) + + async def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- - async def handle_callback(self, request: BoltRequest) -> BoltResponse: + async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: # failure due to end-user's cancellation or invalid redirection to slack.com error = request.query.get("error", [None])[0] if error is not None: - return await self.build_callback_failure_response( - request, reason=error, status=200 + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason=error, + suggested_status_code=200, + settings=self.settings, + default=self.default_callback_options, + ) ) # state parameter verification - state = request.query.get("state", [None])[0] - if not self.oauth_state_utils.is_valid_browser(state, request.headers): - return await self.build_callback_failure_response( - request, reason="invalid_browser", status=400 - ) - - valid_state_consumed = await self.oauth_state_store.async_consume(state) - if not valid_state_consumed: - return await self.build_callback_failure_response( - request, reason="invalid_state", status=401 - ) + if self.settings.state_validation_enabled is True: + state: Optional[str] = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) + ) + + valid_state_consumed = await self.settings.state_store.async_consume(state) # type: ignore[arg-type] + if not valid_state_consumed: + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) + ) # run installation code = request.query.get("code", [None])[0] if code is None: - return await self.build_callback_failure_response( - request, reason="missing_code", status=401 + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="missing_code", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) + installation = await self.run_installation(code) if installation is None: # failed to run installation with the code - return await self.build_callback_failure_response( - request, reason="invalid_code", status=401 + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_code", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) # persist the installation try: await self.store_installation(request, installation) - except BoltError as e: - return await self.build_callback_failure_response( - request, reason="storage_error", error=e + except BoltError as err: + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="storage_error", + error=err, + suggested_status_code=500, + settings=self.settings, + default=self.default_callback_options, + ) ) # display a successful completion page to the end-user - return await self.build_callback_success_response(request, installation) + return await self.success_handler( + AsyncSuccessArgs( + request=request, + installation=installation, + settings=self.settings, + default=self.default_callback_options, + ) + ) # ---------------------- # Internal methods for Callback @@ -238,94 +314,57 @@ async def run_installation(self, code: str) -> Optional[Installation]: try: oauth_response: AsyncSlackResponse = await self.client.oauth_v2_access( code=code, - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, # can be None - ) - installed_enterprise: Dict[str, str] = oauth_response.get("enterprise", {}) - installed_team: Dict[str, str] = oauth_response.get("team", {}) - installer: Dict[str, str] = oauth_response.get("authed_user", {}) - incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook", {} + client_id=self.settings.client_id, + client_secret=self.settings.client_secret, + redirect_uri=self.settings.redirect_uri, # can be None ) + installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {} + is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False + installed_team: Dict[str, str] = oauth_response.get("team") or {} + installer: Dict[str, str] = oauth_response.get("authed_user") or {} + incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {} - bot_token: Optional[str] = oauth_response.get("access_token", None) + bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response bot_id: Optional[str] = None + enterprise_url: Optional[str] = None if bot_token is not None: auth_test = await self.client.auth_test(token=bot_token) bot_id = auth_test["bot_id"] + if is_enterprise_install is True: + enterprise_url = auth_test.get("url") return Installation( - app_id=oauth_response.get("app_id", None), - enterprise_id=installed_enterprise.get("id", None), - team_id=installed_team.get("id", None), + app_id=oauth_response.get("app_id"), + enterprise_id=installed_enterprise.get("id"), + enterprise_name=installed_enterprise.get("name"), + enterprise_url=enterprise_url, + team_id=installed_team.get("id"), + team_name=installed_team.get("name"), bot_token=bot_token, bot_id=bot_id, - bot_user_id=oauth_response.get("bot_user_id", None), - bot_scopes=oauth_response.get("scope", None), # comma-separated string - user_id=installer.get("id", None), - user_token=installer.get("access_token", None), - user_scopes=installer.get("scope", None), # comma-separated string - incoming_webhook_url=incoming_webhook.get("url", None), - incoming_webhook_channel_id=incoming_webhook.get("channel_id", None), - incoming_webhook_configuration_url=incoming_webhook.get( - "configuration_url", None - ), + bot_user_id=oauth_response.get("bot_user_id"), + bot_scopes=oauth_response.get("scope"), # type: ignore[arg-type] # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 + user_id=installer.get("id"), # type: ignore[arg-type] + user_token=installer.get("access_token"), + user_scopes=installer.get("scope"), # type: ignore[arg-type]# comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # type: ignore[arg-type] # since v1.7 + incoming_webhook_url=incoming_webhook.get("url"), + incoming_webhook_channel=incoming_webhook.get("channel"), + incoming_webhook_channel_id=incoming_webhook.get("channel_id"), + incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"), + is_enterprise_install=is_enterprise_install, + token_type=oauth_response.get("token_type"), ) except SlackApiError as e: - message = ( - f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}" - ) + message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}" self.logger.warning(message) return None - async def store_installation( - self, request: BoltRequest, installation: Installation - ): + async def store_installation(self, request: AsyncBoltRequest, installation: Installation): # may raise BoltError - await self.installation_store.async_save(installation) - - async def build_callback_failure_response( - self, - request: BoltRequest, - reason: str, - status: int = 500, - error: Optional[Exception] = None, - ) -> BoltResponse: - debug_message = ( - "Handling an OAuth callback failure " - f"(reason: {reason}, error: {error}, request: {request.query})" - ) - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_failure_page(reason) - return BoltResponse( - status=status, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) - - async def build_callback_success_response( - self, request: BoltRequest, installation: Installation, - ) -> BoltResponse: - debug_message = f"Handling an OAuth callback success (request: {request.query})" - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_success_page( - app_id=installation.app_id, team_id=installation.team_id, - ) - return BoltResponse( - status=200, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) + await self.settings.installation_store.async_save(installation) diff --git a/slack_bolt/oauth/async_oauth_settings.py b/slack_bolt/oauth/async_oauth_settings.py new file mode 100644 index 000000000..e8513b3d3 --- /dev/null +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -0,0 +1,181 @@ +import logging +import os +from logging import Logger +from typing import Optional, Sequence, Union + +from slack_sdk.oauth import ( + OAuthStateUtils, + AuthorizeUrlGenerator, + RedirectUriPageRenderer, +) +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore + +from slack_bolt.authorization.async_authorize import ( + AsyncInstallationStoreAuthorize, + AsyncAuthorize, +) +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_callback_options import AsyncCallbackOptions +from slack_bolt.oauth.async_internals import get_or_create_default_installation_store + + +class AsyncOAuthSettings: + # OAuth flow parameters/credentials + client_id: str + client_secret: str + scopes: Optional[Sequence[str]] + user_scopes: Optional[Sequence[str]] + redirect_uri: Optional[str] + # Handler configuration + install_path: str + install_page_rendering_enabled: bool + redirect_uri_path: str + callback_options: Optional[AsyncCallbackOptions] = None + success_url: Optional[str] + failure_url: Optional[str] + authorization_url: str # default: https://slack.com/oauth/v2/authorize + # Installation Management + installation_store: AsyncInstallationStore + installation_store_bot_only: bool + token_rotation_expiration_minutes: int + user_token_resolution: str + authorize: AsyncAuthorize + # state parameter related configurations + state_validation_enabled: bool + state_store: AsyncOAuthStateStore + state_cookie_name: str + state_expiration_seconds: int + # Customizable utilities + state_utils: OAuthStateUtils + authorize_url_generator: AuthorizeUrlGenerator + redirect_uri_page_renderer: RedirectUriPageRenderer + # Others + logger: Logger + + def __init__( + self, + *, + # OAuth flow parameters/credentials + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required + scopes: Optional[Union[Sequence[str], str]] = None, + user_scopes: Optional[Union[Sequence[str], str]] = None, + redirect_uri: Optional[str] = None, + # Handler configuration + install_path: str = "/slack/install", + install_page_rendering_enabled: bool = True, + redirect_uri_path: str = "/slack/oauth_redirect", + callback_options: Optional[AsyncCallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + authorization_url: Optional[str] = None, + # Installation Management + installation_store: Optional[AsyncInstallationStore] = None, + installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", + # state parameter related configurations + state_validation_enabled: bool = True, + state_store: Optional[AsyncOAuthStateStore] = None, + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # Others + logger: Logger = logging.getLogger(__name__), + ): + """The settings for Slack App installation (OAuth flow). + + Args: + client_id: Check the value in Settings > Basic Information > App Credentials + client_secret: Check the value in Settings > Basic Information > App Credentials + scopes: Check the value in Settings > Manage Distribution + user_scopes: Check the value in Settings > Manage Distribution + redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs + install_path: The endpoint to start an OAuth flow (Default: `/slack/install`) + install_page_rendering_enabled: Renders a web page for install_path access if True + redirect_uri_path: The path of Redirect URL (Default: `/slack/oauth_redirect`) + callback_options: Give success/failure functions f you want to customize callback functions. + success_url: Set a complete URL if you want to redirect end-users when an installation completes. + failure_url: Set a complete URL if you want to redirect end-users when an installation fails. + authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize` + installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) + installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) + state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) + state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") + state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) + logger: The logger that will be used internally + """ + # OAuth flow parameters/credentials + client_id = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET") + if client_id is None or client_secret is None: + raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret + + self.scopes = scopes if scopes is not None else os.environ.get("SLACK_SCOPES", "").split(",") + if isinstance(self.scopes, str): + self.scopes = self.scopes.split(",") + self.user_scopes = user_scopes if user_scopes is not None else os.environ.get("SLACK_USER_SCOPES", "").split(",") + if isinstance(self.user_scopes, str): + self.user_scopes = self.user_scopes.split(",") + + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") + # Handler configuration + self.install_path = install_path or os.environ.get("SLACK_INSTALL_PATH", "/slack/install") + self.install_page_rendering_enabled = install_page_rendering_enabled + self.redirect_uri_path = redirect_uri_path or os.environ.get("SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect") + self.callback_options = callback_options + self.success_url = success_url + self.failure_url = failure_url + self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" + # Installation Management + self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" + self.installation_store_bot_only = installation_store_bot_only + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes + self.authorize = AsyncInstallationStoreAuthorize( + logger=logger, + client_id=self.client_id, + client_secret=self.client_secret, + token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, + installation_store=self.installation_store, + bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, + ) + # state parameter related configurations + self.state_validation_enabled = state_validation_enabled + self.state_store = state_store or FileOAuthStateStore( + expiration_seconds=state_expiration_seconds, + client_id=client_id, + ) + self.state_cookie_name = state_cookie_name + self.state_expiration_seconds = state_expiration_seconds + + self.state_utils = OAuthStateUtils( + cookie_name=self.state_cookie_name, + expiration_seconds=self.state_expiration_seconds, + ) + self.authorize_url_generator = AuthorizeUrlGenerator( + client_id=self.client_id, + redirect_uri=self.redirect_uri, + scopes=self.scopes, + user_scopes=self.user_scopes, + authorization_url=self.authorization_url, + ) + self.redirect_uri_page_renderer = RedirectUriPageRenderer( + install_path=self.install_path, + redirect_uri_path=self.redirect_uri_path, + success_url=self.success_url, + failure_url=self.failure_url, + ) diff --git a/slack_bolt/oauth/callback_options.py b/slack_bolt/oauth/callback_options.py new file mode 100644 index 000000000..09584a365 --- /dev/null +++ b/slack_bolt/oauth/callback_options.py @@ -0,0 +1,121 @@ +import logging +from logging import Logger +from typing import Optional, Callable, TYPE_CHECKING + +from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils +from slack_sdk.oauth.installation_store import Installation + +from slack_bolt.oauth.internals import CallbackResponseBuilder +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + +if TYPE_CHECKING: + from slack_bolt.oauth.oauth_settings import OAuthSettings + + +class SuccessArgs: + def __init__( + self, + *, + request: BoltRequest, + installation: Installation, + settings: "OAuthSettings", + default: "CallbackOptions", + ): + """The arguments for a success function. + + Args: + request: The request. + installation: The installation data. + settings: The settings for Slack OAuth flow. + default: The default `CallbackOptions` + """ + self.request = request + self.installation = installation + self.settings = settings + self.default = default + + +class FailureArgs: + def __init__( + self, + *, + request: BoltRequest, + reason: str, + error: Optional[Exception] = None, + suggested_status_code: int, + settings: "OAuthSettings", + default: "CallbackOptions", + ): + """The arguments for a failure function. + + Args: + request: The request. + reason: The response. + error: An exception if exists. + suggested_status_code: The recommended HTTP status code for the failure. + settings: The settings for Slack OAuth flow. + default: The default `CallbackOptions`. + """ + self.request = request + self.reason = reason + self.error = error + self.suggested_status_code = suggested_status_code + self.settings = settings + self.default = default + + +class CallbackOptions: + success: Callable[[SuccessArgs], BoltResponse] + failure: Callable[[FailureArgs], BoltResponse] + + def __init__( + self, + success: Callable[[SuccessArgs], BoltResponse], + failure: Callable[[FailureArgs], BoltResponse], + ): + """The configurations for OAuth flow. + + Args: + success: A handler for successful installation. + failure: A handler for any types of installation failures. + """ + self.success = success + self.failure = failure + + +class DefaultCallbackOptions(CallbackOptions): + success: Callable[[SuccessArgs], BoltResponse] + failure: Callable[[FailureArgs], BoltResponse] + + def __init__( + self, + *, + logger: Logger, + state_utils: OAuthStateUtils, + redirect_uri_page_renderer: RedirectUriPageRenderer, + ): + self._response_builder = CallbackResponseBuilder( + logger=logger or logging.getLogger(__name__), + state_utils=state_utils, + redirect_uri_page_renderer=redirect_uri_page_renderer, + ) + self.success = self._success_handler + self.failure = self._failure_handler + + # -------------------------- + # Internal methods + # -------------------------- + + def _success_handler(self, args: SuccessArgs) -> BoltResponse: + return self._response_builder._build_callback_success_response( + request=args.request, + installation=args.installation, + ) + + def _failure_handler(self, args: FailureArgs) -> BoltResponse: + return self._response_builder._build_callback_failure_response( + request=args.request, + reason=args.reason, + status=args.suggested_status_code, + ) diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py new file mode 100644 index 000000000..05959817a --- /dev/null +++ b/slack_bolt/oauth/internals.py @@ -0,0 +1,145 @@ +import html +from logging import Logger +from typing import Dict, Optional +from typing import Union + +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth import OAuthStateUtils, RedirectUriPageRenderer +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.installation_store import Installation + +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from ..logger.messages import warning_installation_store_conflicts + + +class CallbackResponseBuilder: + def __init__( + self, + *, + logger: Logger, + state_utils: OAuthStateUtils, + redirect_uri_page_renderer: RedirectUriPageRenderer, + ): + self._logger = logger + self._state_utils = state_utils + self._redirect_uri_page_renderer = redirect_uri_page_renderer + + def _build_callback_success_response( + self, + request: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore[name-defined] + installation: Installation, + ) -> BoltResponse: + debug_message = f"Handling an OAuth callback success (request: {request.query})" + self._logger.debug(debug_message) + + page_content = self._redirect_uri_page_renderer.render_success_page( + app_id=installation.app_id, # type: ignore[arg-type] + team_id=installation.team_id, + is_enterprise_install=installation.is_enterprise_install, + enterprise_url=installation.enterprise_url, + ) + return BoltResponse( + status=200, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), + }, + body=page_content, + ) + + def _build_callback_failure_response( + self, + request: Union[BoltRequest, "AsyncBoltRequest"], # type: ignore[name-defined] + reason: str, + status: int = 500, + error: Optional[Exception] = None, + ) -> BoltResponse: + debug_message = "Handling an OAuth callback failure " f"(reason: {reason}, error: {error}, request: {request.query})" + self._logger.debug(debug_message) + + # Adding a bit more details to the error code to help installers understand what's happening. + # This modification in the HTML page works only when developers use this built-in failure handler. + detailed_error = build_detailed_error(reason) + return BoltResponse( + status=status, + headers={ + "Content-Type": "text/html; charset=utf-8", + "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), + }, + body=self._redirect_uri_page_renderer.render_failure_page(detailed_error), + ) + + +def _build_default_install_page_html(url: str) -> str: + return f""" + + + + + +

Slack App Installation

+

Add to Slack

+ + +""" # noqa: E501 + + +# key: client_id, value: InstallationStore +default_installation_stores: Dict[str, InstallationStore] = {} + + +def get_or_create_default_installation_store(client_id: str) -> InstallationStore: + store = default_installation_stores.get(client_id) + if store is None: + store = FileInstallationStore(client_id=client_id) + default_installation_stores[client_id] = store + return store + + +def select_consistent_installation_store( + client_id: str, + app_store: Optional[InstallationStore], + oauth_flow_store: Optional[InstallationStore], + logger: Logger, +) -> Optional[InstallationStore]: + default = get_or_create_default_installation_store(client_id) + if app_store is not None: + if oauth_flow_store is not None: + if oauth_flow_store is default: + # only app_store is intentionally set in this case + return app_store + + # if both are intentionally set, prioritize app_store + if oauth_flow_store is not app_store: + logger.warning(warning_installation_store_conflicts()) + return oauth_flow_store + else: + # only app_store is available + return app_store + else: + # only oauth_flow_store is available + return oauth_flow_store + + +def build_detailed_error(reason: str) -> str: + if reason == "invalid_browser": + return ( + f"{reason}: This can occur due to page reload, " + "not beginning the OAuth flow from the valid starting URL, or " + "the /slack/install URL not using https://" + ) + elif reason == "invalid_state": + return f"{reason}: The state parameter is no longer valid." + elif reason == "missing_code": + return f"{reason}: The code parameter is missing in this redirection." + elif reason == "storage_error": + return f"{reason}: The app's server encountered an issue. Contact the app developer." + else: + return f"{html.escape(reason)}: This error code is returned from Slack. Refer to the documents for details." diff --git a/slack_bolt/oauth/oauth_flow.py b/slack_bolt/oauth/oauth_flow.py index 6c6289708..542860848 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -1,20 +1,24 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict +from typing import Optional, Dict, Callable, Sequence from slack_bolt.error import BoltError +from slack_bolt.oauth.callback_options import ( + FailureArgs, + SuccessArgs, + DefaultCallbackOptions, + CallbackOptions, +) +from slack_bolt.oauth.internals import _build_default_install_page_html + +from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError -from slack_sdk.oauth import ( - AuthorizeUrlGenerator, - OAuthStateUtils, - RedirectUriPageRenderer, -) -from slack_sdk.oauth.installation_store import InstallationStore, Installation +from slack_sdk.oauth import OAuthStateUtils +from slack_sdk.oauth.installation_store import Installation from slack_sdk.oauth.installation_store.sqlite3 import SQLite3InstallationStore -from slack_sdk.oauth.state_store import OAuthStateStore from slack_sdk.oauth.state_store.sqlite3 import SQLite3OAuthStateStore from slack_sdk.web import WebClient, SlackResponse @@ -22,95 +26,61 @@ class OAuthFlow: - installation_store: InstallationStore - oauth_state_store: OAuthStateStore - oauth_state_cookie_name: str - oauth_state_expiration_seconds: int - + settings: OAuthSettings client_id: str - client_secret: str redirect_uri: Optional[str] - scopes: Optional[List[str]] - user_scopes: Optional[List[str]] - install_path: str redirect_uri_path: str - success_url: Optional[str] - failure_url: Optional[str] - oauth_state_utils: OAuthStateUtils - authorize_url_generator: AuthorizeUrlGenerator - redirect_uri_page_renderer: RedirectUriPageRenderer - @property - def client(self) -> WebClient: - if self._client is None: - self._client = create_web_client() - return self._client - - @property - def logger(self) -> Logger: - if self._logger is None: - self._logger = logging.getLogger(__name__) - return self._logger + success_handler: Callable[[SuccessArgs], BoltResponse] + failure_handler: Callable[[FailureArgs], BoltResponse] def __init__( self, *, client: Optional[WebClient] = None, logger: Optional[Logger] = None, - installation_store: InstallationStore, - oauth_state_store: OAuthStateStore, - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, - client_id: str, - client_secret: str, - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, - redirect_uri: Optional[str] = None, - install_path: str = "/slack/install", - redirect_uri_path: str = "/slack/oauth_redirect", - success_url: Optional[str] = None, - failure_url: Optional[str] = None, + settings: OAuthSettings, ): + """The module to run the Slack app installation flow (OAuth flow). + + Args: + client: The `slack_sdk.web.WebClient` instance. + logger: The logger. + settings: OAuth settings to configure this module. + """ self._client = client self._logger = logger + self.settings = settings + if self._logger is not None: + self.settings.logger = self._logger + + self.client_id = self.settings.client_id + self.redirect_uri = self.settings.redirect_uri + self.install_path = self.settings.install_path + self.redirect_uri_path = self.settings.redirect_uri_path + + self.default_callback_options = DefaultCallbackOptions( + logger=logger, # type: ignore[arg-type] + state_utils=self.settings.state_utils, + redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, + ) + if settings.callback_options is None: + settings.callback_options = self.default_callback_options + self.success_handler = settings.callback_options.success + self.failure_handler = settings.callback_options.failure - self.installation_store = installation_store - self.oauth_state_store = oauth_state_store - self.oauth_state_cookie_name = oauth_state_cookie_name - self.oauth_state_expiration_seconds = oauth_state_expiration_seconds - - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.scopes = scopes - self.user_scopes = user_scopes - - self.install_path = install_path - self.redirect_uri_path = redirect_uri_path - self.success_url = success_url - self.failure_url = failure_url - - self._init_internal_utils() + @property + def client(self) -> WebClient: + if self._client is None: + self._client = create_web_client(logger=self.logger) + return self._client - def _init_internal_utils(self): - self.oauth_state_utils = OAuthStateUtils( - cookie_name=self.oauth_state_cookie_name, - expiration_seconds=self.oauth_state_expiration_seconds, - ) - self.authorize_url_generator = AuthorizeUrlGenerator( - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, - scopes=self.scopes, - user_scopes=self.user_scopes, - ) - self.redirect_uri_page_renderer = RedirectUriPageRenderer( - install_path=self.install_path, - redirect_uri_path=self.redirect_uri_path, - success_url=self.success_url, - failure_url=self.failure_url, - ) + @property + def logger(self) -> Logger: + if self._logger is None: + self._logger = logging.getLogger(__name__) + return self._logger # ----------------------------- # Factory Methods @@ -120,34 +90,70 @@ def _init_internal_utils(self): def sqlite3( cls, database: str, - client_id: Optional[str] = os.environ.get("SLACK_CLIENT_ID", None), - client_secret: Optional[str] = os.environ.get("SLACK_CLIENT_SECRET", None), - scopes: List[str] = os.environ.get("SLACK_SCOPES", "").split(","), - user_scopes: List[str] = os.environ.get("SLACK_USER_SCOPES", "").split(","), - redirect_uri: Optional[str] = os.environ.get("SLACK_REDIRECT_URI", None), - oauth_state_cookie_name: str = OAuthStateUtils.default_cookie_name, - oauth_state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # OAuth flow parameters/credentials + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, + redirect_uri: Optional[str] = None, + # Handler configuration + install_path: Optional[str] = None, + redirect_uri_path: Optional[str] = None, + callback_options: Optional[CallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + authorization_url: Optional[str] = None, + # Installation Management + # state parameter related configurations + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, + client: Optional[WebClient] = None, logger: Optional[Logger] = None, ) -> "OAuthFlow": + client_id = client_id or os.environ["SLACK_CLIENT_ID"] # required + client_secret = client_secret or os.environ["SLACK_CLIENT_SECRET"] # required + scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") + user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split(",") + redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") + installation_store = ( + SQLite3InstallationStore(database=database, client_id=client_id) + if logger is None + else SQLite3InstallationStore(database=database, client_id=client_id, logger=logger) + ) + state_store = ( + SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds) + if logger is None + else SQLite3OAuthStateStore(database=database, expiration_seconds=state_expiration_seconds, logger=logger) + ) return OAuthFlow( - client=WebClient(), + client=client or WebClient(), logger=logger, - installation_store=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, - ), - oauth_state_store=SQLite3OAuthStateStore( - database=database, - expiration_seconds=oauth_state_expiration_seconds, - logger=logger, + settings=OAuthSettings( + # OAuth flow parameters/credentials + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + user_scopes=user_scopes, + redirect_uri=redirect_uri, + # Handler configuration + install_path=install_path, # type: ignore[arg-type] + redirect_uri_path=redirect_uri_path, # type: ignore[arg-type] + callback_options=callback_options, + success_url=success_url, + failure_url=failure_url, + authorization_url=authorization_url, + # Installation Management + installation_store=installation_store, + installation_store_bot_only=installation_store_bot_only, + token_rotation_expiration_minutes=token_rotation_expiration_minutes, + # state parameter related configurations + state_store=state_store, + state_cookie_name=state_cookie_name, + state_expiration_seconds=state_expiration_seconds, ), - oauth_state_cookie_name=oauth_state_cookie_name, - oauth_state_expiration_seconds=oauth_state_expiration_seconds, - client_id=client_id, - client_secret=client_secret, - scopes=scopes, - user_scopes=user_scopes, - redirect_uri=redirect_uri, ) # ----------------------------- @@ -155,28 +161,54 @@ def sqlite3( # ----------------------------- def handle_installation(self, request: BoltRequest) -> BoltResponse: - state = self.issue_new_state(request) - return self.build_authorize_url_redirection(request, state) + set_cookie_value: Optional[str] = None + url = self.build_authorize_url("", request) + if self.settings.state_validation_enabled is True: + state = self.issue_new_state(request) + url = self.build_authorize_url(state, request) + set_cookie_value = self.settings.state_utils.build_set_cookie_for_new_state(state) + + if self.settings.install_page_rendering_enabled: + html = self.build_install_page_html(url, request) + return BoltResponse( + status=200, + body=html, + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8"}, + set_cookie_value, + ), + ) + else: + return BoltResponse( + status=302, + body="", + headers=self.append_set_cookie_headers( + {"Content-Type": "text/html; charset=utf-8", "Location": url}, + set_cookie_value, + ), + ) # ---------------------- # Internal methods for Installation def issue_new_state(self, request: BoltRequest) -> str: - return self.oauth_state_store.issue() - - def build_authorize_url_redirection( - self, request: BoltRequest, state: str - ) -> BoltResponse: - return BoltResponse( - status=302, - headers={ - "Location": [self.authorize_url_generator.generate(state)], - "Set-Cookie": [ - self.oauth_state_utils.build_set_cookie_for_new_state(state) - ], - }, + return self.settings.state_store.issue() + + def build_authorize_url(self, state: str, request: BoltRequest) -> str: + team_ids: Optional[Sequence[str]] = request.query.get("team") + return self.settings.authorize_url_generator.generate( + state=state, + team=team_ids[0] if team_ids is not None else None, ) + def build_install_page_html(self, url: str, request: BoltRequest) -> str: + return _build_default_install_page_html(url) + + def append_set_cookie_headers(self, headers: dict, set_cookie_value: Optional[str]): + if set_cookie_value is not None: + headers["Set-Cookie"] = [set_cookie_value] + return headers + # ----------------------------- # Callback # ----------------------------- @@ -186,46 +218,92 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: # failure due to end-user's cancellation or invalid redirection to slack.com error = request.query.get("error", [None])[0] if error is not None: - return self.build_callback_failure_response( - request, reason=error, status=200 + return self.failure_handler( + FailureArgs( + request=request, + reason=error, + suggested_status_code=200, + settings=self.settings, + default=self.default_callback_options, + ) ) # state parameter verification - state = request.query.get("state", [None])[0] - if not self.oauth_state_utils.is_valid_browser(state, request.headers): - return self.build_callback_failure_response( - request, reason="invalid_browser", status=400 - ) - - valid_state_consumed = self.oauth_state_store.consume(state) - if not valid_state_consumed: - return self.build_callback_failure_response( - request, reason="invalid_state", status=401 - ) + if self.settings.state_validation_enabled is True: + state = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) + ) + + valid_state_consumed = self.settings.state_store.consume(state) # type: ignore[arg-type] + if not valid_state_consumed: + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_state", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) + ) # run installation code = request.query.get("code", [None])[0] if code is None: - return self.build_callback_failure_response( - request, reason="missing_code", status=401 + return self.failure_handler( + FailureArgs( + request=request, + reason="missing_code", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) + installation = self.run_installation(code) if installation is None: # failed to run installation with the code - return self.build_callback_failure_response( - request, reason="invalid_code", status=401 + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_code", + suggested_status_code=401, + settings=self.settings, + default=self.default_callback_options, + ) ) # persist the installation try: self.store_installation(request, installation) - except BoltError as e: - return self.build_callback_failure_response( - request, reason="storage_error", error=e + except BoltError as err: + return self.failure_handler( + FailureArgs( + request=request, + reason="storage_error", + error=err, + suggested_status_code=500, + settings=self.settings, + default=self.default_callback_options, + ) ) # display a successful completion page to the end-user - return self.build_callback_success_response(request, installation) + return self.success_handler( + SuccessArgs( + request=request, + installation=installation, + settings=self.settings, + default=self.default_callback_options, + ) + ) # ---------------------- # Internal methods for Callback @@ -234,92 +312,57 @@ def run_installation(self, code: str) -> Optional[Installation]: try: oauth_response: SlackResponse = self.client.oauth_v2_access( code=code, - client_id=self.client_id, - client_secret=self.client_secret, - redirect_uri=self.redirect_uri, # can be None - ) - installed_enterprise: Dict[str, str] = oauth_response.get("enterprise", {}) - installed_team: Dict[str, str] = oauth_response.get("team", {}) - installer: Dict[str, str] = oauth_response.get("authed_user", {}) - incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook", {} + client_id=self.settings.client_id, + client_secret=self.settings.client_secret, + redirect_uri=self.settings.redirect_uri, # can be None ) + installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {} + is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False + installed_team: Dict[str, str] = oauth_response.get("team") or {} + installer: Dict[str, str] = oauth_response.get("authed_user") or {} + incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {} - bot_token: Optional[str] = oauth_response.get("access_token", None) + bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response bot_id: Optional[str] = None + enterprise_url: Optional[str] = None if bot_token is not None: auth_test = self.client.auth_test(token=bot_token) bot_id = auth_test["bot_id"] + if is_enterprise_install is True: + enterprise_url = auth_test.get("url") return Installation( - app_id=oauth_response.get("app_id", None), - enterprise_id=installed_enterprise.get("id", None), - team_id=installed_team.get("id", None), + app_id=oauth_response.get("app_id"), + enterprise_id=installed_enterprise.get("id"), + enterprise_name=installed_enterprise.get("name"), + enterprise_url=enterprise_url, + team_id=installed_team.get("id"), + team_name=installed_team.get("name"), bot_token=bot_token, bot_id=bot_id, - bot_user_id=oauth_response.get("bot_user_id", None), - bot_scopes=oauth_response.get("scope", None), # comma-separated string - user_id=installer.get("id", None), - user_token=installer.get("access_token", None), - user_scopes=installer.get("scope", None), # comma-separated string - incoming_webhook_url=incoming_webhook.get("url", None), - incoming_webhook_channel_id=incoming_webhook.get("channel_id", None), - incoming_webhook_configuration_url=incoming_webhook.get( - "configuration_url", None - ), + bot_user_id=oauth_response.get("bot_user_id"), + bot_scopes=oauth_response.get("scope"), # type: ignore[arg-type] # comma-separated string + bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7 + bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7 + user_id=installer.get("id"), # type: ignore[arg-type] + user_token=installer.get("access_token"), + user_scopes=installer.get("scope"), # type: ignore[arg-type] # comma-separated string + user_refresh_token=installer.get("refresh_token"), # since v1.7 + user_token_expires_in=installer.get("expires_in"), # type: ignore[arg-type] # since v1.7 + incoming_webhook_url=incoming_webhook.get("url"), + incoming_webhook_channel=incoming_webhook.get("channel"), + incoming_webhook_channel_id=incoming_webhook.get("channel_id"), + incoming_webhook_configuration_url=incoming_webhook.get("configuration_url"), + is_enterprise_install=is_enterprise_install, + token_type=oauth_response.get("token_type"), ) except SlackApiError as e: - message = ( - f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}" - ) + message = f"Failed to fetch oauth.v2.access result with code: {code} - error: {e}" self.logger.warning(message) return None def store_installation(self, request: BoltRequest, installation: Installation): # may raise BoltError - self.installation_store.save(installation) - - def build_callback_failure_response( - self, - request: BoltRequest, - reason: str, - status: int = 500, - error: Optional[Exception] = None, - ) -> BoltResponse: - debug_message = ( - "Handling an OAuth callback failure " - f"(reason: {reason}, error: {error}, request: {request.query})" - ) - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_failure_page(reason) - return BoltResponse( - status=status, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) - - def build_callback_success_response( - self, request: BoltRequest, installation: Installation, - ) -> BoltResponse: - debug_message = f"Handling an OAuth callback success (request: {request.query})" - self.logger.debug(debug_message) - - html = self.redirect_uri_page_renderer.render_success_page( - app_id=installation.app_id, team_id=installation.team_id, - ) - return BoltResponse( - status=200, - headers={ - "Content-Type": "text/html; charset=utf-8", - "Content-Length": len(html), - "Set-Cookie": self.oauth_state_utils.build_set_cookie_for_deletion(), - }, - body=html, - ) + self.settings.installation_store.save(installation) diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py new file mode 100644 index 000000000..ec2727f75 --- /dev/null +++ b/slack_bolt/oauth/oauth_settings.py @@ -0,0 +1,174 @@ +import logging +import os +from logging import Logger +from typing import Optional, Sequence, Union + +from slack_sdk.oauth import ( + OAuthStateStore, + InstallationStore, + OAuthStateUtils, + AuthorizeUrlGenerator, + RedirectUriPageRenderer, +) +from slack_sdk.oauth.state_store import FileOAuthStateStore + +from slack_bolt.authorization.authorize import Authorize, InstallationStoreAuthorize +from slack_bolt.error import BoltError +from slack_bolt.oauth.internals import get_or_create_default_installation_store +from slack_bolt.oauth.callback_options import CallbackOptions + + +class OAuthSettings: + # OAuth flow parameters/credentials + client_id: str + client_secret: str + scopes: Optional[Sequence[str]] + user_scopes: Optional[Sequence[str]] + redirect_uri: Optional[str] + # Handler configuration + install_path: str + install_page_rendering_enabled: bool + redirect_uri_path: str + callback_options: Optional[CallbackOptions] = None + success_url: Optional[str] + failure_url: Optional[str] + authorization_url: str # default: https://slack.com/oauth/v2/authorize + # Installation Management + installation_store: InstallationStore + installation_store_bot_only: bool + token_rotation_expiration_minutes: int + authorize: Authorize + user_token_resolution: str # default: "authed_user" + # state parameter related configurations + state_validation_enabled: bool + state_store: OAuthStateStore + state_cookie_name: str + state_expiration_seconds: int + # Customizable utilities + state_utils: OAuthStateUtils + authorize_url_generator: AuthorizeUrlGenerator + redirect_uri_page_renderer: RedirectUriPageRenderer + # Others + logger: Logger + + def __init__( + self, + *, + # OAuth flow parameters/credentials + client_id: Optional[str] = None, # required + client_secret: Optional[str] = None, # required + scopes: Optional[Union[Sequence[str], str]] = None, + user_scopes: Optional[Union[Sequence[str], str]] = None, + redirect_uri: Optional[str] = None, + # Handler configuration + install_path: str = "/slack/install", + install_page_rendering_enabled: bool = True, + redirect_uri_path: str = "/slack/oauth_redirect", + callback_options: Optional[CallbackOptions] = None, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + authorization_url: Optional[str] = None, + # Installation Management + installation_store: Optional[InstallationStore] = None, + installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, + user_token_resolution: str = "authed_user", + # state parameter related configurations + state_validation_enabled: bool = True, + state_store: Optional[OAuthStateStore] = None, + state_cookie_name: str = OAuthStateUtils.default_cookie_name, + state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + # Others + logger: Logger = logging.getLogger(__name__), + ): + """The settings for Slack App installation (OAuth flow). + + Args: + client_id: Check the value in Settings > Basic Information > App Credentials + client_secret: Check the value in Settings > Basic Information > App Credentials + scopes: Check the value in Settings > Manage Distribution + user_scopes: Check the value in Settings > Manage Distribution + redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs + install_path: The endpoint to start an OAuth flow (Default: `/slack/install`) + install_page_rendering_enabled: Renders a web page for install_path access if True + redirect_uri_path: The path of Redirect URL (Default: `/slack/oauth_redirect`) + callback_options: Give success/failure functions f you want to customize callback functions. + success_url: Set a complete URL if you want to redirect end-users when an installation completes. + failure_url: Set a complete URL if you want to redirect end-users when an installation fails. + authorization_url: Set a URL if you want to customize the URL `https://slack.com/oauth/v2/authorize` + installation_store: Specify the instance of `InstallationStore` (Default: `FileInstallationStore`) + installation_store_bot_only: Use `InstallationStore#find_bot()` if True (Default: False) + token_rotation_expiration_minutes: Minutes before refreshing tokens (Default: 2 hours) + user_token_resolution: The option to pick up a user token per request (Default: authed_user) + The available values are "authed_user" and "actor". When you want to resolve the user token per request + using the event's actor IDs, you can set "actor" instead. With this option, bolt-python tries to resolve + a user token for context.actor_enterprise/team/user_id. This can be useful for events in Slack Connect + channels. Note that actor IDs can be absent in some scenarios. + state_validation_enabled: Set False if your OAuth flow omits the state parameter validation (Default: True) + state_store: Specify the instance of `InstallationStore` (Default: `FileOAuthStateStore`) + state_cookie_name: The cookie name that is set for installers' browser. (Default: "slack-app-oauth-state") + state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) + logger: The logger that will be used internally + """ + client_id = client_id or os.environ.get("SLACK_CLIENT_ID") + client_secret = client_secret or os.environ.get("SLACK_CLIENT_SECRET") + if client_id is None or client_secret is None: + raise BoltError("Both client_id and client_secret are required") + self.client_id = client_id + self.client_secret = client_secret + + self.scopes = scopes if scopes is not None else os.environ.get("SLACK_SCOPES", "").split(",") + if isinstance(self.scopes, str): + self.scopes = self.scopes.split(",") + self.user_scopes = user_scopes if user_scopes is not None else os.environ.get("SLACK_USER_SCOPES", "").split(",") + if isinstance(self.user_scopes, str): + self.user_scopes = self.user_scopes.split(",") + self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") + # Handler configuration + self.install_path = install_path or os.environ.get("SLACK_INSTALL_PATH", "/slack/install") + self.install_page_rendering_enabled = install_page_rendering_enabled + self.redirect_uri_path = redirect_uri_path or os.environ.get("SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect") + self.callback_options = callback_options + self.success_url = success_url + self.failure_url = failure_url + self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" + # Installation Management + self.installation_store = installation_store or get_or_create_default_installation_store(client_id) + self.user_token_resolution = user_token_resolution or "authed_user" + self.installation_store_bot_only = installation_store_bot_only + self.token_rotation_expiration_minutes = token_rotation_expiration_minutes + self.authorize = InstallationStoreAuthorize( + logger=logger, + client_id=self.client_id, + client_secret=self.client_secret, + token_rotation_expiration_minutes=self.token_rotation_expiration_minutes, + installation_store=self.installation_store, + bot_only=self.installation_store_bot_only, + user_token_resolution=user_token_resolution, + ) + # state parameter related configurations + self.state_validation_enabled = state_validation_enabled + self.state_store = state_store or FileOAuthStateStore( + expiration_seconds=state_expiration_seconds, + client_id=client_id, + ) + self.state_cookie_name = state_cookie_name + self.state_expiration_seconds = state_expiration_seconds + + self.state_utils = OAuthStateUtils( + cookie_name=self.state_cookie_name, + expiration_seconds=self.state_expiration_seconds, + ) + self.authorize_url_generator = AuthorizeUrlGenerator( + client_id=self.client_id, + redirect_uri=self.redirect_uri, + scopes=self.scopes, + user_scopes=self.user_scopes, + authorization_url=self.authorization_url, + ) + self.redirect_uri_page_renderer = RedirectUriPageRenderer( + install_path=self.install_path, + redirect_uri_path=self.redirect_uri_path, + success_url=self.success_url, + failure_url=self.failure_url, + ) diff --git a/slack_bolt/request/__init__.py b/slack_bolt/request/__init__.py index 16f2ef5c4..8610b6019 100644 --- a/slack_bolt/request/__init__.py +++ b/slack_bolt/request/__init__.py @@ -1,2 +1,12 @@ +"""Incoming request from Slack through either HTTP request or Socket Mode connection. + +Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections. +This interface encapsulates the difference between the two. +""" + # Don't add async module imports here from .request import BoltRequest + +__all__ = [ + "BoltRequest", +] diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index c87707ac1..ea94739e8 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -3,27 +3,68 @@ from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.internals import ( extract_enterprise_id, + extract_function_bot_access_token, + extract_function_execution_id, + extract_function_inputs, + extract_is_enterprise_install, extract_team_id, extract_user_id, extract_channel_id, + debug_multiple_response_urls_detected, + extract_actor_enterprise_id, + extract_actor_team_id, + extract_actor_user_id, + extract_thread_ts, ) def build_async_context( - context: AsyncBoltContext, payload: Dict[str, Any], + context: AsyncBoltContext, + body: Dict[str, Any], ) -> AsyncBoltContext: - enterprise_id = extract_enterprise_id(payload) + context["is_enterprise_install"] = extract_is_enterprise_install(body) + enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id - team_id = extract_team_id(payload) + team_id = extract_team_id(body) if team_id: context["team_id"] = team_id - user_id = extract_user_id(payload) + user_id = extract_user_id(body) if user_id: context["user_id"] = user_id - channel_id = extract_channel_id(payload) + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id + channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id - if "response_url" in payload: - context["response_url"] = payload["response_url"] + thread_ts = extract_thread_ts(body) + if thread_ts: + context["thread_ts"] = thread_ts + function_execution_id = extract_function_execution_id(body) + if function_execution_id: + context["function_execution_id"] = function_execution_id + function_bot_access_token = extract_function_bot_access_token(body) + if function_bot_access_token is not None: + context["function_bot_access_token"] = function_bot_access_token + function_inputs = extract_function_inputs(body) + if function_inputs is not None: + context["inputs"] = function_inputs + if "response_url" in body: + context["response_url"] = body["response_url"] + elif "response_urls" in body: + # In the case where response_url_enabled: true in a modal exists + response_urls = body["response_urls"] + if len(response_urls) >= 1: + if len(response_urls) > 1: + context.logger.debug(debug_multiple_response_urls_detected()) + response_url = response_urls[0].get("response_url") + context["response_url"] = response_url return context diff --git a/slack_bolt/request/async_request.py b/slack_bolt/request/async_request.py index f2b9c11a6..73891446e 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -1,37 +1,83 @@ -from typing import Dict, Optional, List, Union, Any +from typing import Dict, Optional, Union, Any, Sequence from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError from slack_bolt.request.async_internals import build_async_context from slack_bolt.request.internals import ( parse_query, - parse_payload, + parse_body, build_normalized_headers, extract_content_type, + error_message_raw_body_required_in_http_mode, ) class AsyncBoltRequest: - body: str - query: Dict[str, List[str]] - headers: Dict[str, List[str]] + raw_body: str + body: Dict[str, Any] + query: Dict[str, Sequence[str]] + headers: Dict[str, Sequence[str]] content_type: Optional[str] - payload: Dict[str, Any] context: AsyncBoltContext + lazy_only: bool + lazy_function_name: Optional[str] + mode: str # either "http" or "socket_mode" def __init__( self, *, - body: str, - query: Optional[Union[str, Dict[str, str], Dict[str, List[str]]]] = None, - # many framework use Dict[str, str] but the reality is Dict[str, List[str]] - headers: Optional[Dict[str, Union[str, List[str]]]] = None, - context: Optional[Dict[str, str]] = None, + body: Union[str, dict], + query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, + headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, + context: Optional[Dict[str, Any]] = None, + mode: str = "http", # either "http" or "socket_mode" ): - self.body = body + """Request to a Bolt app. + + Args: + body: The raw request body (only plain text is supported for "http" mode) + query: The query string data in any data format. + headers: The request headers. + context: The context in this request. + mode: The mode used for this request. (either "http" or "socket_mode") + """ + + if mode == "http": + # HTTP Mode + if body is not None and not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) - self.payload = parse_payload(self.body, self.content_type) - self.context = build_async_context( - AsyncBoltContext(context if context else {}), self.payload + + if isinstance(body, str): + self.body = parse_body(self.raw_body, self.content_type) + elif isinstance(body, dict): + self.body = body + else: + self.body = {} + + self.context = build_async_context(AsyncBoltContext(context if context else {}), self.body) + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) + self.lazy_function_name = self.headers.get("x-slack-bolt-lazy-function-name", [None])[0] + self.mode = mode + + def to_copyable(self) -> "AsyncBoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body + return AsyncBoltRequest( + body=body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, ) diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 905b8d691..e0863a713 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -1,141 +1,317 @@ import json -from typing import Optional, Dict, Union, List, Any +from typing import Optional, Dict, Union, Any, Sequence from urllib.parse import parse_qsl, parse_qs from slack_bolt.context import BoltContext -def parse_query( - query: Optional[Union[str, Dict[str, str], Dict[str, List[str]]]] -) -> Dict[str, List[str]]: +def parse_query(query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]]) -> Dict[str, Sequence[str]]: if query is None: return {} elif isinstance(query, str): - return parse_qs(query) + return dict(parse_qs(query, keep_blank_values=True)) elif isinstance(query, dict) or hasattr(query, "items"): - result: Dict[str, List[str]] = {} + result: Dict[str, Sequence[str]] = {} for name, value in query.items(): if isinstance(value, list): result[name] = value elif isinstance(value, str): result[name] = [value] else: - raise ValueError( - f"Unsupported type ({type(value)}) of element in headers ({query})" - ) - return result # type: ignore + raise ValueError(f"Unsupported type ({type(value)}) of element in headers ({query})") + return result else: raise ValueError(f"Unsupported type of query detected ({type(query)})") -def parse_payload(body: str, content_type: Optional[str]) -> Dict[str, Any]: +def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]: if not body: return {} - if content_type == "application/json" or body.startswith("{"): + if (content_type is not None and content_type == "application/json") or body.startswith("{"): return json.loads(body) - elif content_type == "application/x-www-form-urlencoded": - if "payload" in body: - params = dict(parse_qsl(body)) - if "payload" in params: - return json.loads(params.get("payload")) + else: + if "payload" in body: # This is not JSON format yet + params = dict(parse_qsl(body, keep_blank_values=True)) + payload = params.get("payload") + if payload is not None: + return json.loads(payload) else: return {} else: - return dict(parse_qsl(body)) - else: - return {} + return dict(parse_qsl(body, keep_blank_values=True)) + + +def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]: + if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0: + # To make Events API handling functioning also for shared channels, + # we should use .authorizations[0].is_enterprise_install over .is_enterprise_install + return extract_is_enterprise_install(payload["authorizations"][0]) + if "is_enterprise_install" in payload: + is_enterprise_install = payload.get("is_enterprise_install") + return is_enterprise_install is not None and (is_enterprise_install is True or is_enterprise_install == "true") + return False def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: - if "enterprise" in payload: - org = payload.get("enterprise") + org = payload.get("enterprise") + if org is not None: if isinstance(org, str): return org elif "id" in org: - return org.get("id") # type: ignore + return org.get("id") + if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0: + # To make Events API handling functioning also for shared channels, + # we should use .authorizations[0].enterprise_id over .enterprise_id + return extract_enterprise_id(payload["authorizations"][0]) if "enterprise_id" in payload: return payload.get("enterprise_id") - if "team" in payload and "enterprise_id" in payload["team"]: + if 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", None) - if "event" in payload: + return payload["team"].get("enterprise_id") + if isinstance(payload.get("event"), dict): return extract_enterprise_id(payload["event"]) return None +def extract_actor_enterprise_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + # For safety, we don't set actor IDs for the events like "file_shared", + # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct. + event_team_id = payload.get("event", {}).get("user_team") or payload.get("event", {}).get("team") + if event_team_id is not None and str(event_team_id).startswith("E"): + return event_team_id + if event_team_id == payload.get("team_id"): + return payload.get("enterprise_id") + return None + return extract_enterprise_id(payload) + + def extract_team_id(payload: Dict[str, Any]) -> Optional[str]: - if "team" in payload: + 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 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 team = payload.get("team") if isinstance(team, str): return team elif team and "id" in team: return team.get("id") + if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0: + # To make Events API handling functioning also for shared channels, + # we should use .authorizations[0].team_id over .team_id + return extract_team_id(payload["authorizations"][0]) if "team_id" in payload: return payload.get("team_id") - if "event" in payload: + if isinstance(payload.get("event"), dict): return extract_team_id(payload["event"]) - if "user" in payload: - return payload.get("user")["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 +def extract_actor_team_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + event_type = payload.get("event", {}).get("type") + if event_type == "app_mention": + # The $.event.user_team can be an enterprise_id in app_mention events. + # In the scenario, there is no way to retrieve actor_team_id as of March 2023 + user_team = payload.get("event", {}).get("user_team") + if user_team is None: + # working with an app installed in this user's org/workspace side + return payload.get("event", {}).get("team") + if str(user_team).startswith("T"): + # interacting from a connected non-grid workspace + return user_team + # Interacting from a connected grid workspace; in this case, team_id cannot be resolved as of March 2023 + return None + # For safety, we don't set actor IDs for the events like "file_shared", + # which do not provide any team ID in $.event data. In the case, the IDs cannot be correct. + event_user_team = payload.get("event", {}).get("user_team") + if event_user_team is not None: + if str(event_user_team).startswith("T"): + return event_user_team + elif str(event_user_team).startswith("E"): + if event_user_team == payload.get("enterprise_id"): + return payload.get("team_id") + elif event_user_team == payload.get("context_enterprise_id"): + return payload.get("context_team_id") + + event_team = payload.get("event", {}).get("team") + if event_team is not None: + if str(event_team).startswith("T"): + return event_team + elif str(event_team).startswith("E"): + if event_team == payload.get("enterprise_id"): + return payload.get("team_id") + elif event_team == payload.get("context_enterprise_id"): + return payload.get("context_team_id") + return None + + return extract_team_id(payload) + + def extract_user_id(payload: Dict[str, Any]) -> Optional[str]: - if "user" in payload: - user = payload.get("user") + user = payload.get("user") + if user is not None: if isinstance(user, str): return user elif "id" in user: - return user.get("id") # type: ignore + return user.get("id") if "user_id" in payload: return payload.get("user_id") - if "event" in payload: + if isinstance(payload.get("event"), dict): return extract_user_id(payload["event"]) + if isinstance(payload.get("message"), dict): + # message_changed: body["event"]["message"] + return extract_user_id(payload["message"]) + if isinstance(payload.get("previous_message"), dict): + # message_deleted: body["event"]["previous_message"] + return extract_user_id(payload["previous_message"]) return None +def extract_actor_user_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("is_ext_shared_channel") is True: + if payload.get("type") == "event_callback": + event = payload.get("event") + if event is None: + return None + if extract_actor_enterprise_id(payload) is None and extract_actor_team_id(payload) is None: + # When both enterprise_id and team_id are not identified, we skip returning user_id too for safety + return None + return event.get("user") or event.get("user_id") + return extract_user_id(payload) + + def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: - if "channel" in payload: - channel = payload.get("channel") + channel = payload.get("channel") + if channel is not None: if isinstance(channel, str): return channel elif "id" in channel: - return channel.get("id") # type: ignore + return channel.get("id") if "channel_id" in payload: return payload.get("channel_id") - if "event" in payload: + if isinstance(payload.get("event"), dict): return extract_channel_id(payload["event"]) + if isinstance(payload.get("item"), dict): + # reaction_added: body["event"]["item"] + return extract_channel_id(payload["item"]) + if isinstance(payload.get("assistant_thread"), dict): + # assistant_thread_started + return extract_channel_id(payload["assistant_thread"]) return None -def build_context(context: BoltContext, payload: Dict[str, Any],) -> BoltContext: - enterprise_id = extract_enterprise_id(payload) +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 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 + + +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 isinstance(payload.get("event"), dict): + return extract_function_execution_id(payload["event"]) + if isinstance(payload.get("function_data"), dict): + return payload["function_data"].get("execution_id") + return None + + +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 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 isinstance(payload.get("event"), dict): + return payload["event"].get("inputs") + if isinstance(payload.get("function_data"), dict): + return payload["function_data"].get("inputs") + return None + + +def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: + context["is_enterprise_install"] = extract_is_enterprise_install(body) + enterprise_id = extract_enterprise_id(body) if enterprise_id: context["enterprise_id"] = enterprise_id - team_id = extract_team_id(payload) + team_id = extract_team_id(body) if team_id: context["team_id"] = team_id - user_id = extract_user_id(payload) + user_id = extract_user_id(body) if user_id: context["user_id"] = user_id - channel_id = extract_channel_id(payload) + # Actor IDs are useful for Events API on a Slack Connect channel + actor_enterprise_id = extract_actor_enterprise_id(body) + if actor_enterprise_id: + context["actor_enterprise_id"] = actor_enterprise_id + actor_team_id = extract_actor_team_id(body) + if actor_team_id: + context["actor_team_id"] = actor_team_id + actor_user_id = extract_actor_user_id(body) + if actor_user_id: + context["actor_user_id"] = actor_user_id + channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id - if "response_url" in payload: - context["response_url"] = payload["response_url"] + thread_ts = extract_thread_ts(body) + if thread_ts: + context["thread_ts"] = thread_ts + function_execution_id = extract_function_execution_id(body) + if function_execution_id is not None: + context["function_execution_id"] = function_execution_id + function_bot_access_token = extract_function_bot_access_token(body) + if function_bot_access_token is not None: + context["function_bot_access_token"] = function_bot_access_token + inputs = extract_function_inputs(body) + if inputs is not None: + context["inputs"] = inputs + if "response_url" in body: + context["response_url"] = body["response_url"] + elif "response_urls" in body: + # In the case where response_url_enabled: true in a modal exists + response_urls = body["response_urls"] + if len(response_urls) >= 1: + if len(response_urls) > 1: + context.logger.debug(debug_multiple_response_urls_detected()) + response_url = response_urls[0].get("response_url") + context["response_url"] = response_url return context -def extract_content_type(headers: Dict[str, List[str]]) -> Optional[str]: +def extract_content_type(headers: Dict[str, Sequence[str]]) -> Optional[str]: content_type: Optional[str] = headers.get("content-type", [None])[0] if content_type: return content_type.split(";")[0] return None -def build_normalized_headers( - headers: Optional[Dict[str, Union[str, List[str]]]] -) -> Dict[str, List[str]]: - normalized_headers: Dict[str, List[str]] = {} +def build_normalized_headers(headers: Optional[Dict[str, Union[str, Sequence[str]]]]) -> Dict[str, Sequence[str]]: + normalized_headers: Dict[str, Sequence[str]] = {} if headers is not None: for key, value in headers.items(): normalized_name = key.lower() @@ -144,7 +320,17 @@ def build_normalized_headers( elif isinstance(value, str): normalized_headers[normalized_name] = [value] else: - raise ValueError( - f"Unsupported type ({type(value)}) of element in headers ({headers})" - ) - return normalized_headers # type: ignore + raise ValueError(f"Unsupported type ({type(value)}) of element in headers ({headers})") + return normalized_headers + + +def error_message_raw_body_required_in_http_mode() -> str: + return "`body` must be a raw string data when running in the HTTP server mode" + + +def debug_multiple_response_urls_detected() -> str: + return ( + "`response_urls` in the body has multiple URLs in it. " + "If you would like to use non-primary one, " + "please manually extract the one from body['response_urls']." + ) diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py new file mode 100644 index 000000000..b74238461 --- /dev/null +++ b/slack_bolt/request/payload_utils.py @@ -0,0 +1,268 @@ +from typing import Dict, Any, Optional + +# ------------------------------------------ +# Public Utilities +# ------------------------------------------ + +# ------------------- +# Events API +# ------------------- + + +def to_event(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return body["event"] if is_event(body) else None + + +def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_message_event(body): + return to_event(body) + return None + + +def is_function(body: Dict[str, Any]) -> bool: + return is_event(body) and "function_executed" == body["event"]["type"] and "function_execution_id" in body["event"] + + +def is_event(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "event_callback") and "event" in body and "type" in body["event"] + + +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) + or is_assistant_thread_context_changed_event(body) + or is_user_message_event_in_assistant_thread(body) + or is_bot_message_event_in_assistant_thread(body) + ) + + +def is_assistant_thread_started_event(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "assistant_thread_started" + return False + + +def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "assistant_thread_context_changed" + return False + + +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_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_any_im_message_event(body): + return ( + body["event"].get("subtype") is None + and body["event"].get("thread_ts") is not None + and body["event"].get("bot_id") is not None + ) + return False + + +def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) -> bool: + # message_changed, message_deleted etc. + 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 + + +def _is_other_message_sub_event(message: Optional[Dict[str, Any]]) -> bool: + return message is not None and (message.get("subtype") == "assistant_app_thread" or message.get("thread_ts") is not None) + + +# ------------------- +# Slash Commands +# ------------------- + + +def to_command(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return body if is_slash_command(body) else None + + +def is_slash_command(body: Dict[str, Any]) -> bool: + return body is not None and "command" in body + + +# ------------------- +# Actions +# ------------------- + + +def to_action(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_action(body): + if is_block_actions(body) or is_attachment_action(body): + return body["actions"][0] + else: + return body + return None + + +def is_action(body: Dict[str, Any]) -> bool: + return ( + is_attachment_action(body) + or is_block_actions(body) + or is_dialog_submission(body) + or is_dialog_cancellation(body) + or is_workflow_step_edit(body) + ) + + +def is_attachment_action(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "interactive_message") and "callback_id" in body + + +def is_block_actions(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "block_actions") and "actions" in body + + +def is_dialog_submission(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "dialog_submission") and "callback_id" in body + + +def is_dialog_cancellation(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "dialog_cancellation") and "callback_id" in body + + +def is_workflow_step_edit(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "workflow_step_edit") and "callback_id" in body + + +# ------------------- +# Options +# ------------------- + + +def to_options(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_options(body): + return body + return None + + +def is_options(body: Dict[str, Any]) -> bool: + return is_block_suggestion(body) or is_dialog_suggestion(body) + + +def is_block_suggestion(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "block_suggestion") and "action_id" in body + + +def is_dialog_suggestion(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "dialog_suggestion") and "callback_id" in body + + +# ------------------- +# Shortcut +# ------------------- + + +def to_shortcut(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_shortcut(body): + return body + return None + + +def is_shortcut(body: Dict[str, Any]) -> bool: + return is_global_shortcut(body) or is_message_shortcut(body) + + +def is_global_shortcut(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "shortcut") and "callback_id" in body + + +def is_message_shortcut(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "message_action") and "callback_id" in body + + +# ------------------- +# View +# ------------------- + + +def to_view(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if is_view(body): + return body["view"] + return None + + +def is_view(body: Dict[str, Any]) -> bool: + return is_view_submission(body) or is_view_closed(body) + + +def is_view_submission(body: Dict[str, Any]) -> bool: + return ( + body is not None and _is_expected_type(body, "view_submission") and "view" in body and "callback_id" in body["view"] + ) + + +def is_view_closed(body: Dict[str, Any]) -> bool: + return body is not None and _is_expected_type(body, "view_closed") and "view" in body and "callback_id" in body["view"] + + +def is_workflow_step_save(body: Dict[str, Any]) -> bool: + return is_view_submission(body) and body["view"]["type"] == "workflow_step" + + +# ------------------- +# Steps From Apps +# ------------------- + + +def to_step(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + # edit + if is_workflow_step_edit(body): + return body["workflow_step"] + # save + if is_workflow_step_save(body): + return body["workflow_step"] + # execute + if is_workflow_step_execute(body): + return body["event"]["workflow_step"] + return None + + +# ------------------------------------------ +# Internal Utilities +# ------------------------------------------ + + +def _is_expected_type(body: dict, expected: str) -> bool: + return body is not None and "type" in body and body["type"] == expected diff --git a/slack_bolt/request/request.py b/slack_bolt/request/request.py index ea3d0c27f..2a418a33f 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -1,37 +1,82 @@ -from typing import Dict, Optional, List, Union, Any +from typing import Dict, Optional, Union, Any, Sequence from slack_bolt.context.context import BoltContext +from slack_bolt.error import BoltError from slack_bolt.request.internals import ( parse_query, - parse_payload, + parse_body, build_normalized_headers, build_context, extract_content_type, + error_message_raw_body_required_in_http_mode, ) class BoltRequest: - body: str - query: Dict[str, List[str]] - headers: Dict[str, List[str]] + raw_body: str + query: Dict[str, Sequence[str]] + headers: Dict[str, Sequence[str]] content_type: Optional[str] - payload: Dict[str, Any] + body: Dict[str, Any] context: BoltContext + lazy_only: bool + lazy_function_name: Optional[str] + mode: str # either "http" or "socket_mode" def __init__( self, *, - body: str, - query: Optional[Union[str, Dict[str, str], Dict[str, List[str]]]] = None, - # many framework use Dict[str, str] but the reality is Dict[str, List[str]] - headers: Optional[Dict[str, Union[str, List[str]]]] = None, - context: Optional[Dict[str, str]] = None, + body: Union[str, dict], + query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]] = None, + headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, + context: Optional[Dict[str, Any]] = None, + mode: str = "http", # either "http" or "socket_mode" ): - self.body = body + """Request to a Bolt app. + + Args: + body: The raw request body (only plain text is supported for "http" mode) + query: The query string data in any data format. + headers: The request headers. + context: The context in this request. + mode: The mode used for this request. (either "http" or "socket_mode") + """ + if mode == "http": + # HTTP Mode + if body is not None and not isinstance(body, str): + raise BoltError(error_message_raw_body_required_in_http_mode()) + self.raw_body = body if body is not None else "" + else: + # Socket Mode + if body is not None and isinstance(body, str): + self.raw_body = body + else: + # We don't convert the dict value to str + # as doing so does not guarantee to keep the original structure/format. + self.raw_body = "" + self.query = parse_query(query) self.headers = build_normalized_headers(headers) self.content_type = extract_content_type(self.headers) - self.payload = parse_payload(self.body, self.content_type) - self.context = build_context( - BoltContext(context if context else {}), self.payload + + if isinstance(body, str): + self.body = parse_body(self.raw_body, self.content_type) + elif isinstance(body, dict): + self.body = body + else: + self.body = {} + + self.context = build_context(BoltContext(context if context else {}), self.body) + self.lazy_only = bool(self.headers.get("x-slack-bolt-lazy-only", [False])[0]) + self.lazy_function_name = self.headers.get("x-slack-bolt-lazy-function-name", [None])[0] + self.mode = mode + + def to_copyable(self) -> "BoltRequest": + body: Union[str, dict] = self.raw_body if self.mode == "http" else self.body + return BoltRequest( + body=body, + query=self.query, + headers=self.headers, + context=self.context.to_copyable(), + mode=self.mode, ) diff --git a/slack_bolt/response/__init__.py b/slack_bolt/response/__init__.py index b4e8ec3e0..c390b2d8e 100644 --- a/slack_bolt/response/__init__.py +++ b/slack_bolt/response/__init__.py @@ -1 +1,13 @@ +"""This interface represents Bolt's synchronous response to Slack. + +In Socket Mode, the response data can be transformed to a WebSocket message. In the HTTP endpoint mode, +the response data becomes an HTTP response data. + +Refer to https://docs.slack.dev/apis/events-api/ for the two types of connections. +""" + from .response import BoltResponse + +__all__ = [ + "BoltResponse", +] diff --git a/slack_bolt/response/response.py b/slack_bolt/response/response.py index 6f39d78e2..227b4fa22 100644 --- a/slack_bolt/response/response.py +++ b/slack_bolt/response/response.py @@ -1,23 +1,30 @@ import json from http.cookies import SimpleCookie -from typing import Union, Dict, List, Optional +from typing import Union, Dict, Optional, Sequence class BoltResponse: status: int body: str - headers: Dict[str, List[str]] + headers: Dict[str, Sequence[str]] def __init__( self, *, status: int, body: Union[str, dict] = "", - headers: Optional[Dict[str, Union[str, List[str]]]] = None, + headers: Optional[Dict[str, Union[str, Sequence[str]]]] = None, ): + """The response from a Bolt app. + + Args: + status: HTTP status code + body: The response body (dict and str are supported) + headers: The response headers. + """ self.status: int = status self.body: str = json.dumps(body) if isinstance(body, dict) else body - self.headers: Dict[str, List[str]] = {} + self.headers: Dict[str, Sequence[str]] = {} if headers is not None: for name, value in headers.items(): if value is None: @@ -41,7 +48,7 @@ def first_headers(self) -> Dict[str, str]: def first_headers_without_set_cookie(self) -> Dict[str, str]: return {k: list(v)[0] for k, v in self.headers.items() if k != "set-cookie"} - def cookies(self) -> List[SimpleCookie]: + def cookies(self) -> Sequence[SimpleCookie]: header_values = self.headers.get("set-cookie", []) return [self._to_simple_cookie(v) for v in header_values] diff --git a/slack_bolt/util/__init__.py b/slack_bolt/util/__init__.py index e69de29bb..2f85bff8d 100644 --- a/slack_bolt/util/__init__.py +++ b/slack_bolt/util/__init__.py @@ -0,0 +1 @@ +"""Internal utilities for the Bolt framework.""" diff --git a/slack_bolt/util/async_utils.py b/slack_bolt/util/async_utils.py index 3713f83e6..858583063 100644 --- a/slack_bolt/util/async_utils.py +++ b/slack_bolt/util/async_utils.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Optional from slack_sdk.web.async_client import AsyncWebClient @@ -5,5 +6,9 @@ from slack_bolt.version import __version__ as bolt_version -def create_async_web_client(token: Optional[str] = None) -> AsyncWebClient: - return AsyncWebClient(token=token, user_agent_prefix=f"Bolt-Async/{bolt_version}",) +def create_async_web_client(token: Optional[str] = None, logger: Optional[Logger] = None) -> AsyncWebClient: + return AsyncWebClient( + token=token, + logger=logger, + user_agent_prefix=f"Bolt-Async/{bolt_version}", + ) diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 09ce24bad..9ee313821 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -1,4 +1,8 @@ -from typing import Optional, Union, Dict, List +import copy +import inspect +import sys +from logging import Logger +from typing import Optional, Union, Dict, Any, Sequence, Callable, List from slack_sdk import WebClient from slack_sdk.models import JsonObject @@ -7,17 +11,86 @@ from slack_bolt.version import __version__ as bolt_version -def create_web_client(token: Optional[str] = None) -> WebClient: - return WebClient(token=token, user_agent_prefix=f"Bolt/{bolt_version}",) +def create_web_client(token: Optional[str] = None, logger: Optional[Logger] = None) -> WebClient: + return WebClient( + token=token, + logger=logger, + user_agent_prefix=f"Bolt/{bolt_version}", + ) -def convert_to_dict_list(objects: List[Union[Dict, JsonObject]]) -> List[Dict]: - return [_to_dict(elm) for elm in objects] +def convert_to_dict_list(objects: Sequence[Union[Dict, JsonObject]]) -> Sequence[Dict]: + return [convert_to_dict(elm) for elm in objects] -def _to_dict(obj: Union[Dict, JsonObject]) -> Dict: +def convert_to_dict(obj: Union[Dict, JsonObject]) -> Dict: if isinstance(obj, dict): return obj if isinstance(obj, JsonObject) or hasattr(obj, "to_dict"): return obj.to_dict() raise BoltError(f"{obj} (type: {type(obj)}) is unsupported") + + +def create_copy(original: Any) -> Any: + return copy.deepcopy(original) + + +def get_boot_message(development_server: bool = False) -> str: + if sys.platform == "win32": + # Some Windows environments may fail to parse this str value + # and result in UnicodeEncodeError + if development_server: + return "Bolt app is running! (development server)" + else: + return "Bolt app is running!" + + try: + if development_server: + return "⚡️ Bolt app is running! (development server)" + else: + return "⚡️ Bolt app is running!" + except ValueError: + # ValueError is a runtime exception for a given value + # It's a super class of UnicodeEncodeError, which may be raised in the scenario + # see also: https://github.com/slackapi/bolt-python/issues/170 + if development_server: + return "Bolt app is running! (development server)" + else: + return "Bolt app is running!" + + +def get_name_for_callable(func: Callable) -> str: + """Returns the name for the given Callable function object. + + Args: + func: Either a `Callable` instance or a function, which as `__name__` + + Returns: + The name of the given Callable object + """ + if hasattr(func, "__name__"): + return func.__name__ + else: + return f"{func.__class__.__module__}.{func.__class__.__name__}" + + +def get_arg_names_of_callable(func: Callable) -> List[str]: + return inspect.getfullargspec(inspect.unwrap(func)).args + + +def is_callable_coroutine(func: Optional[Any]) -> bool: + return func is not None and ( + inspect.iscoroutinefunction(func) or (hasattr(func, "__call__") and inspect.iscoroutinefunction(func.__call__)) + ) + + +def is_used_without_argument(args) -> bool: + """Tests if a decorator invocation is without () or (args). + + Args: + args: arguments + + Returns: + True if it's an invocation without args + """ + return len(args) == 1 diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 0412dd0aa..2c08c0adb 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1,3 @@ -__version__ = "0.2.3a0" +"""Check the latest version at https://pypi.org/project/slack-bolt/""" + +__version__ = "1.30.0" diff --git a/slack_bolt/workflows/__init__.py b/slack_bolt/workflows/__init__.py new file mode 100644 index 000000000..c0f6d96b7 --- /dev/null +++ b/slack_bolt/workflows/__init__.py @@ -0,0 +1,10 @@ +"""Steps from apps enables developers to build their own steps. + +Check the following API documents first: + +* `slack_bolt.workflows.step.step` +* `slack_bolt.workflows.step.utilities` +* `slack_bolt.workflows.step.async_step` (if you use asyncio-based `AsyncApp`) + +Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. +""" diff --git a/slack_bolt/workflows/step/__init__.py b/slack_bolt/workflows/step/__init__.py new file mode 100644 index 000000000..d7402cb40 --- /dev/null +++ b/slack_bolt/workflows/step/__init__.py @@ -0,0 +1,15 @@ +from .step import WorkflowStep +from .step_middleware import WorkflowStepMiddleware +from .utilities.complete import Complete +from .utilities.configure import Configure +from .utilities.update import Update +from .utilities.fail import Fail + +__all__ = [ + "WorkflowStep", + "WorkflowStepMiddleware", + "Complete", + "Configure", + "Update", + "Fail", +] diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py new file mode 100644 index 000000000..7fa0ed858 --- /dev/null +++ b/slack_bolt/workflows/step/async_step.py @@ -0,0 +1,548 @@ +# mypy: ignore-errors +from functools import wraps +from logging import Logger +from typing import Callable, Union, Optional, Awaitable, Sequence, List, Pattern + +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener +from slack_bolt.listener_matcher.builtins import ( + workflow_step_edit, + workflow_step_save, + workflow_step_execute, +) +from slack_bolt.middleware.async_custom_middleware import AsyncCustomMiddleware +from slack_bolt.response import BoltResponse +from .internals import _is_used_without_argument +from .utilities.async_complete import AsyncComplete +from .utilities.async_configure import AsyncConfigure +from .utilities.async_fail import AsyncFail +from .utilities.async_update import AsyncUpdate +from ...error import BoltError +from ...listener_matcher.async_listener_matcher import ( + AsyncListenerMatcher, + AsyncCustomListenerMatcher, +) +from ...middleware.async_middleware import AsyncMiddleware + + +class AsyncWorkflowStepBuilder: + """Steps from apps + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + """ + + callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] + _edit: Optional[AsyncListener] + _save: Optional[AsyncListener] + _execute: Optional[AsyncListener] + + def __init__( + self, + callback_id: Union[str, Pattern], + app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + This builder is supposed to be used as decorator. + + my_step = AsyncWorkflowStep.builder("my_step") + @my_step.edit + async def edit_my_step(ack, configure): + pass + @my_step.save + async def save_my_step(ack, step, update): + pass + @my_step.execute + async def execute_my_step(step, complete, fail): + pass + app.step(my_step) + + For further information about AsyncWorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents. + + Args: + callback_id: The callback_id for the workflow + app_name: The application name mainly for logging + base_logger: The base logger + """ + self.callback_id = callback_id + self.app_name = app_name or __name__ + self._base_logger = base_logger + self._edit = None + self._save = None + self._execute = None + + def edit( + self, + *args, + matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new edit listener with details. + + You can use this method as decorator as well. + + @my_step.edit + def edit_my_step(ack, configure): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.edit(matchers=[is_valid], middleware=[update_context]) + def edit_my_step(ack, configure): + pass + + For further information about AsyncWorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents. + + Args: + *args: This method can behave as either decorator or a method + matchers: Listener matchers + middleware: Listener middleware + lazy: Lazy listeners + """ + if _is_used_without_argument(args): + func = args[0] + self._edit = self._to_listener("edit", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._edit = self._to_listener("edit", functions, matchers, middleware) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def save( + self, + *args, + matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new save listener with details. + + You can use this method as decorator as well. + + @my_step.save + def save_my_step(ack, step, update): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def save_my_step(ack, step, update): + pass + + For further information about AsyncWorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents. + + Args: + *args: This method can behave as either decorator or a method + matchers: Listener matchers + middleware: Listener middleware + lazy: Lazy listeners + """ + if _is_used_without_argument(args): + func = args[0] + self._save = self._to_listener("save", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._save = self._to_listener("save", functions, matchers, middleware) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def execute( + self, + *args, + matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new execute listener with details. + + You can use this method as decorator as well. + + @my_step.execute + def execute_my_step(step, complete, fail): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def execute_my_step(step, complete, fail): + pass + + For further information about AsyncWorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to the `async` prefixed ones in `slack_bolt.workflows.step.utilities` API documents. + + Args: + *args: This method can behave as either decorator or a method + matchers: Listener matchers + middleware: Listener middleware + lazy: Lazy listeners + """ + if _is_used_without_argument(args): + func = args[0] + self._execute = self._to_listener("execute", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._execute = self._to_listener("execute", functions, matchers, middleware) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def build(self, base_logger: Optional[Logger] = None) -> "AsyncWorkflowStep": + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Constructs a WorkflowStep object. This method may raise an exception + if the builder doesn't have enough configurations to build the object. + + Returns: + An `AsyncWorkflowStep` object + """ + if self._edit is None: + raise BoltError("edit listener is not registered") + if self._save is None: + raise BoltError("save listener is not registered") + if self._execute is None: + raise BoltError("execute listener is not registered") + + return AsyncWorkflowStep( + callback_id=self.callback_id, + edit=self._edit, + save=self._save, + execute=self._execute, + app_name=self.app_name, + base_logger=base_logger, + ) + + # --------------------------------------- + + def _to_listener( + self, + name: str, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], + matchers: Optional[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + ) -> AsyncListener: + return AsyncWorkflowStep.build_listener( + callback_id=self.callback_id, + app_name=self.app_name, + listener_or_functions=listener_or_functions, + name=name, + matchers=self.to_listener_matchers(self.app_name, matchers), + middleware=self.to_listener_middleware(self.app_name, middleware), + base_logger=self._base_logger, + ) + + @staticmethod + def to_listener_matchers( + app_name: str, + matchers: Optional[List[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]]], + ) -> List[AsyncListenerMatcher]: + _matchers = [] + if matchers is not None: + for m in matchers: + if isinstance(m, AsyncListenerMatcher): + _matchers.append(m) + elif isinstance(m, Callable): + _matchers.append(AsyncCustomListenerMatcher(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid matcher: {type(m)}") + return _matchers + + @staticmethod + def to_listener_middleware( + app_name: str, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] + ) -> List[AsyncMiddleware]: + _middleware = [] + if middleware is not None: + for m in middleware: + if isinstance(m, AsyncMiddleware): + _middleware.append(m) + elif isinstance(m, Callable): + _middleware.append(AsyncCustomMiddleware(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid middleware: {type(m)}") + return _middleware + + +class AsyncWorkflowStep: + callback_id: Union[str, Pattern] + """The Callback ID of the step from app""" + edit: AsyncListener + """`edit` listener, which displays a modal in Workflow Builder""" + save: AsyncListener + """`save` listener, which accepts workflow creator's data submission in Workflow Builder""" + execute: AsyncListener + """`execute` listener, which processes the step from app execution""" + + def __init__( + self, + *, + callback_id: Union[str, Pattern], + edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], + save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], + execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener, Sequence[Callable]], + app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Args: + callback_id: The callback_id for this step from app + edit: Either a single function or a list of functions for opening a modal in the builder UI + When it's a list, the first one is responsible for ack() while the rest are lazy listeners. + save: Either a single function or a list of functions for handling modal interactions in the builder UI + When it's a list, the first one is responsible for ack() while the rest are lazy listeners. + execute: Either a single function or a list of functions for handling steps from apps executions + When it's a list, the first one is responsible for ack() while the rest are lazy listeners. + app_name: The app name that can be mainly used for logging + base_logger: The logger instance that can be used as a template when creating this step's logger + """ + self.callback_id = callback_id + app_name = app_name or __name__ + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) + + @classmethod + def builder( + cls, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> AsyncWorkflowStepBuilder: + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + """ + return AsyncWorkflowStepBuilder(callback_id, base_logger=base_logger) + + @classmethod + def build_listener( + cls, + callback_id: Union[str, Pattern], + app_name: str, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], + name: str, + matchers: Optional[List[AsyncListenerMatcher]] = None, + middleware: Optional[List[AsyncMiddleware]] = None, + base_logger: Optional[Logger] = None, + ): + if listener_or_functions is None: + raise BoltError(f"{name} listener is required (callback_id: {callback_id})") + + if isinstance(listener_or_functions, Callable): + listener_or_functions = [listener_or_functions] + + if isinstance(listener_or_functions, AsyncListener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + matchers = matchers if matchers else [] + matchers.insert(0, cls._build_primary_matcher(name, callback_id, base_logger)) + middleware = middleware if middleware else [] + middleware.insert(0, cls._build_single_middleware(name, callback_id, base_logger)) + functions = listener_or_functions + ack_function = functions.pop(0) + return AsyncCustomListener( + app_name=app_name, + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, + auto_acknowledgement=name == "execute", + base_logger=base_logger, + ) + else: + raise BoltError(f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})") + + @classmethod + def _build_primary_matcher( + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, + ) -> AsyncListenerMatcher: + if name == "edit": + return workflow_step_edit(callback_id, asyncio=True, base_logger=base_logger) + elif name == "save": + return workflow_step_save(callback_id, asyncio=True, base_logger=base_logger) + elif name == "execute": + return workflow_step_execute(callback_id, asyncio=True, base_logger=base_logger) + else: + raise ValueError(f"Invalid name {name}") + + @classmethod + def _build_single_middleware( + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, + ) -> AsyncMiddleware: + if name == "edit": + return _build_edit_listener_middleware(callback_id, base_logger) + elif name == "save": + return _build_save_listener_middleware(base_logger) + elif name == "execute": + return _build_execute_listener_middleware(base_logger) + else: + raise ValueError(f"Invalid name {name}") + + +####################### +# Edit +####################### + + +def _build_edit_listener_middleware( + callback_id: str, + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: + async def edit_listener_middleware( + context: AsyncBoltContext, + client: AsyncWebClient, + body: dict, + next: Callable[[], Awaitable[BoltResponse]], + ): + context["configure"] = AsyncConfigure( + callback_id=callback_id, + client=client, + body=body, + ) + return await next() + + return AsyncCustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) + + +####################### +# Save +####################### + + +def _build_save_listener_middleware( + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: + async def save_listener_middleware( + context: AsyncBoltContext, + client: AsyncWebClient, + body: dict, + next: Callable[[], Awaitable[BoltResponse]], + ): + context["update"] = AsyncUpdate( + client=client, + body=body, + ) + return await next() + + return AsyncCustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) + + +####################### +# Execute +####################### + + +def _build_execute_listener_middleware( + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: + async def execute_listener_middleware( + context: AsyncBoltContext, + client: AsyncWebClient, + body: dict, + next: Callable[[], Awaitable[BoltResponse]], + ): + context["complete"] = AsyncComplete( + client=client, + body=body, + ) + context["fail"] = AsyncFail( + client=client, + body=body, + ) + return await next() + + return AsyncCustomMiddleware( + app_name=__name__, + func=execute_listener_middleware, + base_logger=base_logger, + ) diff --git a/slack_bolt/workflows/step/async_step_middleware.py b/slack_bolt/workflows/step/async_step_middleware.py new file mode 100644 index 000000000..5801a51e6 --- /dev/null +++ b/slack_bolt/workflows/step/async_step_middleware.py @@ -0,0 +1,56 @@ +# mypy: ignore-errors +from typing import Callable, Optional, Awaitable + +from slack_bolt.listener.async_listener import AsyncListener +from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_name_for_callable +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep + + +class AsyncWorkflowStepMiddleware(AsyncMiddleware): + """Base middleware for step from app specific ones""" + + def __init__(self, step: AsyncWorkflowStep): + self.step = step + + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> BoltResponse: + + if await self.step.edit.async_matches(req=req, resp=resp): + resp = await self._run(self.step.edit, req, resp) + if resp is not None: + return resp + elif await self.step.save.async_matches(req=req, resp=resp): + resp = await self._run(self.step.save, req, resp) + if resp is not None: + return resp + elif await self.step.execute.async_matches(req=req, resp=resp): + resp = await self._run(self.step.execute, req, resp) + if resp is not None: + return resp + + return await next() + + @staticmethod + async def _run( + listener: AsyncListener, + req: AsyncBoltRequest, + resp: BoltResponse, + ) -> Optional[BoltResponse]: + resp, next_was_not_called = await listener.run_async_middleware(req=req, resp=resp) + if next_was_not_called: + return None + + return await req.context.listener_runner.run( + request=req, + response=resp, + listener_name=get_name_for_callable(listener.ack_function), + listener=listener, + ) diff --git a/slack_bolt/workflows/step/internals.py b/slack_bolt/workflows/step/internals.py new file mode 100644 index 000000000..674b35cd5 --- /dev/null +++ b/slack_bolt/workflows/step/internals.py @@ -0,0 +1,10 @@ +def _is_used_without_argument(args): + """Tests if a decorator invocation is without () or (args). + + Args: + args: arguments + + Returns: + True if it's an invocation without args + """ + return len(args) == 1 diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py new file mode 100644 index 000000000..4fca25717 --- /dev/null +++ b/slack_bolt/workflows/step/step.py @@ -0,0 +1,567 @@ +# mypy: ignore-errors +from functools import wraps +from logging import Logger +from typing import Callable, Union, Optional, Sequence, Pattern, List + +from slack_bolt.context.context import BoltContext +from slack_bolt.error import BoltError +from slack_bolt.listener import Listener, CustomListener +from slack_bolt.listener_matcher import ListenerMatcher, CustomListenerMatcher +from slack_bolt.listener_matcher.builtins import ( + workflow_step_edit, + workflow_step_save, + workflow_step_execute, +) +from slack_bolt.middleware import CustomMiddleware, Middleware +from slack_bolt.response import BoltResponse +from slack_bolt.workflows.step.internals import _is_used_without_argument +from slack_bolt.workflows.step.utilities.complete import Complete +from slack_bolt.workflows.step.utilities.configure import Configure +from slack_bolt.workflows.step.utilities.fail import Fail +from slack_bolt.workflows.step.utilities.update import Update +from slack_sdk.web import WebClient + + +class WorkflowStepBuilder: + """Steps from apps + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + """ + + callback_id: Union[str, Pattern] + _base_logger: Optional[Logger] + _edit: Optional[Listener] + _save: Optional[Listener] + _execute: Optional[Listener] + + def __init__( + self, + callback_id: Union[str, Pattern], + app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + This builder is supposed to be used as decorator. + + my_step = WorkflowStep.builder("my_step") + @my_step.edit + def edit_my_step(ack, configure): + pass + @my_step.save + def save_my_step(ack, step, update): + pass + @my_step.execute + def execute_my_step(step, complete, fail): + pass + app.step(my_step) + + For further information about WorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to `slack_bolt.workflows.step.utilities` API documents. + + Args: + callback_id: The callback_id for the workflow + app_name: The application name mainly for logging + base_logger: The base logger + """ + self.callback_id = callback_id + self.app_name = app_name or __name__ + self._base_logger = base_logger + self._edit = None + self._save = None + self._execute = None + + def edit( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new edit listener with details. + + You can use this method as decorator as well. + + @my_step.edit + def edit_my_step(ack, configure): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.edit(matchers=[is_valid], middleware=[update_context]) + def edit_my_step(ack, configure): + pass + + For further information about WorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to `slack_bolt.workflows.step.utilities` API documents. + + Args: + *args: This method can behave as either decorator or a method + matchers: Listener matchers + middleware: Listener middleware + lazy: Lazy listeners + """ + + if _is_used_without_argument(args): + func = args[0] + self._edit = self._to_listener("edit", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._edit = self._to_listener("edit", functions, matchers, middleware) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def save( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new save listener with details. + + You can use this method as decorator as well. + + @my_step.save + def save_my_step(ack, step, update): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def save_my_step(ack, step, update): + pass + + For further information about WorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to `slack_bolt.workflows.step.utilities` API documents. + + Args: + *args: This method can behave as either decorator or a method + matchers: Listener matchers + middleware: Listener middleware + lazy: Lazy listeners + """ + if _is_used_without_argument(args): + func = args[0] + self._save = self._to_listener("save", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._save = self._to_listener("save", functions, matchers, middleware) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def execute( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Registers a new execute listener with details. + + You can use this method as decorator as well. + + @my_step.execute + def execute_my_step(step, complete, fail): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def execute_my_step(step, complete, fail): + pass + + For further information about WorkflowStep specific function arguments + such as `configure`, `update`, `complete`, and `fail`, + refer to `slack_bolt.workflows.step.utilities` API documents. + + Args: + *args: This method can behave as either decorator or a method + matchers: Listener matchers + middleware: Listener middleware + lazy: Lazy listeners + """ + if _is_used_without_argument(args): + func = args[0] + self._execute = self._to_listener("execute", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._execute = self._to_listener("execute", functions, matchers, middleware) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def build(self, base_logger: Optional[Logger] = None) -> "WorkflowStep": + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Constructs a WorkflowStep object. This method may raise an exception + if the builder doesn't have enough configurations to build the object. + + Returns: + WorkflowStep object + """ + if self._edit is None: + raise BoltError("edit listener is not registered") + if self._save is None: + raise BoltError("save listener is not registered") + if self._execute is None: + raise BoltError("execute listener is not registered") + + return WorkflowStep( + callback_id=self.callback_id, + edit=self._edit, + save=self._save, + execute=self._execute, + app_name=self.app_name, + base_logger=base_logger, + ) + + # --------------------------------------- + + def _to_listener( + self, + name: str, + listener_or_functions: Union[Listener, Callable, List[Callable]], + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + ) -> Listener: + return WorkflowStep.build_listener( + callback_id=self.callback_id, + app_name=self.app_name, + listener_or_functions=listener_or_functions, + name=name, + matchers=self.to_listener_matchers(self.app_name, matchers, self._base_logger), + middleware=self.to_listener_middleware(self.app_name, middleware, self._base_logger), + base_logger=self._base_logger, + ) + + @staticmethod + def to_listener_matchers( + app_name: str, + matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], + base_logger: Optional[Logger] = None, + ) -> List[ListenerMatcher]: + _matchers = [] + if matchers is not None: + for m in matchers: + if isinstance(m, ListenerMatcher): + _matchers.append(m) + elif isinstance(m, Callable): + _matchers.append( + CustomListenerMatcher( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) + else: + raise ValueError(f"Invalid matcher: {type(m)}") + return _matchers + + @staticmethod + def to_listener_middleware( + app_name: str, + middleware: Optional[List[Union[Callable, Middleware]]], + base_logger: Optional[Logger] = None, + ) -> List[Middleware]: + _middleware = [] + if middleware is not None: + for m in middleware: + if isinstance(m, Middleware): + _middleware.append(m) + elif isinstance(m, Callable): + _middleware.append( + CustomMiddleware( + app_name=app_name, + func=m, + base_logger=base_logger, + ) + ) + else: + raise ValueError(f"Invalid middleware: {type(m)}") + return _middleware + + +class WorkflowStep: + callback_id: Union[str, Pattern] + """The Callback ID of the step from app""" + edit: Listener + """`edit` listener, which displays a modal in Workflow Builder""" + save: Listener + """`save` listener, which accepts workflow creator's data submission in Workflow Builder""" + execute: Listener + """`execute` listener, which processes step from app execution""" + + def __init__( + self, + *, + callback_id: Union[str, Pattern], + edit: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], + save: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], + execute: Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]], + app_name: Optional[str] = None, + base_logger: Optional[Logger] = None, + ): + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + + Args: + callback_id: The callback_id for this step from app + edit: Either a single function or a list of functions for opening a modal in the builder UI + When it's a list, the first one is responsible for ack() while the rest are lazy listeners. + save: Either a single function or a list of functions for handling modal interactions in the builder UI + When it's a list, the first one is responsible for ack() while the rest are lazy listeners. + execute: Either a single function or a list of functions for handling step from app executions + When it's a list, the first one is responsible for ack() while the rest are lazy listeners. + app_name: The app name that can be mainly used for logging + base_logger: The logger instance that can be used as a template when creating this step's logger + """ + self.callback_id = callback_id + app_name = app_name or __name__ + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) + + @classmethod + def builder(cls, callback_id: Union[str, Pattern], base_logger: Optional[Logger] = None) -> WorkflowStepBuilder: + """ + Deprecated: + Steps from apps for legacy workflows are now deprecated. + Use new custom steps: https://docs.slack.dev/workflows/workflow-steps/ + """ + return WorkflowStepBuilder( + callback_id, + base_logger=base_logger, + ) + + @classmethod + def build_listener( + cls, + callback_id: Union[str, Pattern], + app_name: str, + listener_or_functions: Union[Listener, Callable, List[Callable]], + name: str, + matchers: Optional[List[ListenerMatcher]] = None, + middleware: Optional[List[Middleware]] = None, + base_logger: Optional[Logger] = None, + ) -> Listener: + if listener_or_functions is None: + raise BoltError(f"{name} listener is required (callback_id: {callback_id})") + + if isinstance(listener_or_functions, Callable): + listener_or_functions = [listener_or_functions] + + if isinstance(listener_or_functions, Listener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + matchers = matchers if matchers else [] + matchers.insert( + 0, + cls._build_primary_matcher( + name, + callback_id, + base_logger=base_logger, + ), + ) + middleware = middleware if middleware else [] + middleware.insert( + 0, + cls._build_single_middleware( + name, + callback_id, + base_logger=base_logger, + ), + ) + functions = listener_or_functions + ack_function = functions.pop(0) + return CustomListener( + app_name=app_name, + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, + auto_acknowledgement=name == "execute", + base_logger=base_logger, + ) + else: + raise BoltError(f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})") + + @classmethod + def _build_primary_matcher( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> ListenerMatcher: + if name == "edit": + return workflow_step_edit(callback_id, base_logger=base_logger) + elif name == "save": + return workflow_step_save(callback_id, base_logger=base_logger) + elif name == "execute": + return workflow_step_execute(callback_id, base_logger=base_logger) + else: + raise ValueError(f"Invalid name {name}") + + @classmethod + def _build_single_middleware( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> Middleware: + if name == "edit": + return _build_edit_listener_middleware(callback_id, base_logger=base_logger) + elif name == "save": + return _build_save_listener_middleware(base_logger=base_logger) + elif name == "execute": + return _build_execute_listener_middleware(base_logger=base_logger) + else: + raise ValueError(f"Invalid name {name}") + + +####################### +# Edit +####################### + + +def _build_edit_listener_middleware(callback_id: str, base_logger: Optional[Logger] = None) -> Middleware: + def edit_listener_middleware( + context: BoltContext, + client: WebClient, + body: dict, + next: Callable[[], BoltResponse], + ): + context["configure"] = Configure( + callback_id=callback_id, + client=client, + body=body, + ) + return next() + + return CustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) + + +####################### +# Save +####################### + + +def _build_save_listener_middleware(base_logger: Optional[Logger] = None) -> Middleware: + def save_listener_middleware( + context: BoltContext, + client: WebClient, + body: dict, + next: Callable[[], BoltResponse], + ): + context["update"] = Update( + client=client, + body=body, + ) + return next() + + return CustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) + + +####################### +# Execute +####################### + + +def _build_execute_listener_middleware( + base_logger: Optional[Logger] = None, +) -> Middleware: + def execute_listener_middleware( + context: BoltContext, + client: WebClient, + body: dict, + next: Callable[[], BoltResponse], + ): + context["complete"] = Complete( + client=client, + body=body, + ) + context["fail"] = Fail( + client=client, + body=body, + ) + return next() + + return CustomMiddleware( + app_name=__name__, + func=execute_listener_middleware, + base_logger=base_logger, + ) diff --git a/slack_bolt/workflows/step/step_middleware.py b/slack_bolt/workflows/step/step_middleware.py new file mode 100644 index 000000000..59af001a7 --- /dev/null +++ b/slack_bolt/workflows/step/step_middleware.py @@ -0,0 +1,59 @@ +# mypy: ignore-errors +from typing import Callable, Optional + +from slack_bolt.listener import Listener +from slack_bolt.middleware import Middleware +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_name_for_callable +from slack_bolt.workflows.step.step import WorkflowStep + + +class WorkflowStepMiddleware(Middleware): + """Base middleware for step from app specific ones""" + + def __init__(self, step: WorkflowStep): + self.step = step + + def process( + self, + *, + req: BoltRequest, + resp: BoltResponse, + # As this method is not supposed to be invoked by bolt-python users, + # the naming conflict with the built-in one affects + # only the internals of this method + next: Callable[[], BoltResponse], + ) -> Optional[BoltResponse]: + + if self.step.edit.matches(req=req, resp=resp): + resp = self._run(self.step.edit, req, resp) + if resp is not None: + return resp + elif self.step.save.matches(req=req, resp=resp): + resp = self._run(self.step.save, req, resp) + if resp is not None: + return resp + elif self.step.execute.matches(req=req, resp=resp): + resp = self._run(self.step.execute, req, resp) + if resp is not None: + return resp + + return next() + + @staticmethod + def _run( + listener: Listener, + req: BoltRequest, + resp: BoltResponse, + ) -> Optional[BoltResponse]: + resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) + if next_was_not_called: + return None + + return req.context.listener_runner.run( + request=req, + response=resp, + listener_name=get_name_for_callable(listener.ack_function), + listener=listener, + ) diff --git a/slack_bolt/workflows/step/utilities/__init__.py b/slack_bolt/workflows/step/utilities/__init__.py new file mode 100644 index 000000000..4cbfed9ce --- /dev/null +++ b/slack_bolt/workflows/step/utilities/__init__.py @@ -0,0 +1,19 @@ +"""Utilities specific to steps from apps. + +In steps from apps listeners, you can use a few specific listener/middleware arguments. + +### `edit` listener + +* `slack_bolt.workflows.step.utilities.configure` for building a modal view + +### `save` listener + +* `slack_bolt.workflows.step.utilities.update` for updating the step metadata + +### `execute` listener + +* `slack_bolt.workflows.step.utilities.fail` for notifying the execution failure to Slack +* `slack_bolt.workflows.step.utilities.complete` for notifying the execution completion to Slack + +For asyncio-based apps, refer to the corresponding `async` prefixed ones. +""" diff --git a/slack_bolt/workflows/step/utilities/async_complete.py b/slack_bolt/workflows/step/utilities/async_complete.py new file mode 100644 index 000000000..b73e22aee --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_complete.py @@ -0,0 +1,36 @@ +from slack_sdk.web.async_client import AsyncWebClient + + +class AsyncComplete: + """`complete()` utility to tell Slack the completion of a step from app execution. + + async def execute(step, complete, fail): + inputs = step["inputs"] + # if everything was successful + outputs = { + "task_name": inputs["task_name"]["value"], + "task_description": inputs["task_description"]["value"], + } + await complete(outputs=outputs) + + ws = AsyncWorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + This utility is a thin wrapper of workflows.stepCompleted API method. + Refer to https://api.slack.com/methods/workflows.stepCompleted for details. + """ + + def __init__(self, *, client: AsyncWebClient, body: dict): + self.client = client + self.body = body + + async def __call__(self, **kwargs) -> None: + await self.client.workflows_stepCompleted( + workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"], + **kwargs, + ) diff --git a/slack_bolt/workflows/step/utilities/async_configure.py b/slack_bolt/workflows/step/utilities/async_configure.py new file mode 100644 index 000000000..5b9a7f9ae --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_configure.py @@ -0,0 +1,55 @@ +from typing import Optional, Union, Sequence + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.models.blocks import Block + + +class AsyncConfigure: + """`configure()` utility to send the modal view in Workflow Builder. + + async def edit(ack, step, configure): + await ack() + + blocks = [ + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "name", + "placeholder": {"type": "plain_text", "text": "Add a task name"}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + ] + await configure(blocks=blocks) + + ws = AsyncWorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + """ + + def __init__(self, *, callback_id: str, client: AsyncWebClient, body: dict): + self.callback_id = callback_id + self.client = client + self.body = body + + async def __call__( + self, + *, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + ) -> None: + await self.client.views_open( + trigger_id=self.body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": self.callback_id, + "blocks": blocks, + }, + ) diff --git a/slack_bolt/workflows/step/utilities/async_fail.py b/slack_bolt/workflows/step/utilities/async_fail.py new file mode 100644 index 000000000..af200bb65 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_fail.py @@ -0,0 +1,37 @@ +from slack_sdk.web.async_client import AsyncWebClient + + +class AsyncFail: + """`fail()` utility to tell Slack the execution failure of a step from app. + + async def execute(step, complete, fail): + inputs = step["inputs"] + # if something went wrong + error = {"message": "Just testing step failure!"} + await fail(error=error) + + ws = AsyncWorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + This utility is a thin wrapper of workflows.stepFailed API method. + Refer to https://api.slack.com/methods/workflows.stepFailed for details. + """ + + def __init__(self, *, client: AsyncWebClient, body: dict): + self.client = client + self.body = body + + async def __call__( + self, + *, + error: dict, + ) -> None: + await self.client.workflows_stepFailed( + workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"], + error=error, + ) diff --git a/slack_bolt/workflows/step/utilities/async_update.py b/slack_bolt/workflows/step/utilities/async_update.py new file mode 100644 index 000000000..d3409bca3 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/async_update.py @@ -0,0 +1,52 @@ +from slack_sdk.web.async_client import AsyncWebClient + + +class AsyncUpdate: + """`update()` utility to tell Slack the processing results of a `save` listener. + + async def save(ack, view, update): + await ack() + + values = view["state"]["values"] + task_name = values["task_name_input"]["name"] + task_description = values["task_description_input"]["description"] + + inputs = { + "task_name": {"value": task_name["value"]}, + "task_description": {"value": task_description["value"]} + } + outputs = [ + { + "type": "text", + "name": "task_name", + "label": "Task name", + }, + { + "type": "text", + "name": "task_description", + "label": "Task description", + } + ] + await update(inputs=inputs, outputs=outputs) + + ws = AsyncWorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + This utility is a thin wrapper of workflows.stepFailed API method. + Refer to https://api.slack.com/methods/workflows.updateStep for details. + """ + + def __init__(self, *, client: AsyncWebClient, body: dict): + self.client = client + self.body = body + + async def __call__(self, **kwargs) -> None: + await self.client.workflows_updateStep( + workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"], + **kwargs, + ) diff --git a/slack_bolt/workflows/step/utilities/complete.py b/slack_bolt/workflows/step/utilities/complete.py new file mode 100644 index 000000000..e17d2f024 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/complete.py @@ -0,0 +1,36 @@ +from slack_sdk.web import WebClient + + +class Complete: + """`complete()` utility to tell Slack the completion of a step from app execution. + + def execute(step, complete, fail): + inputs = step["inputs"] + # if everything was successful + outputs = { + "task_name": inputs["task_name"]["value"], + "task_description": inputs["task_description"]["value"], + } + complete(outputs=outputs) + + ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + This utility is a thin wrapper of workflows.stepCompleted API method. + Refer to https://api.slack.com/methods/workflows.stepCompleted for details. + """ + + def __init__(self, *, client: WebClient, body: dict): + self.client = client + self.body = body + + def __call__(self, **kwargs) -> None: + self.client.workflows_stepCompleted( + workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"], + **kwargs, + ) diff --git a/slack_bolt/workflows/step/utilities/configure.py b/slack_bolt/workflows/step/utilities/configure.py new file mode 100644 index 000000000..1280be8f7 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -0,0 +1,52 @@ +from typing import Optional, Union, Sequence + +from slack_sdk.web import WebClient +from slack_sdk.models.blocks import Block + + +class Configure: + """`configure()` utility to send the modal view in Workflow Builder. + + def edit(ack, step, configure): + ack() + + blocks = [ + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "name", + "placeholder": {"type": "plain_text", "text": "Add a task name"}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + ] + configure(blocks=blocks) + + ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + Refer to https://docs.slack.dev/legacy/legacy-steps-from-apps/ for details. + """ + + def __init__(self, *, callback_id: str, client: WebClient, body: dict): + self.callback_id = callback_id + self.client = client + self.body = body + + def __call__(self, *, blocks: Optional[Sequence[Union[dict, Block]]] = None, **kwargs) -> None: + self.client.views_open( + trigger_id=self.body["trigger_id"], + view={ + "type": "workflow_step", + "callback_id": self.callback_id, + "blocks": blocks, + **kwargs, + }, + ) diff --git a/slack_bolt/workflows/step/utilities/fail.py b/slack_bolt/workflows/step/utilities/fail.py new file mode 100644 index 000000000..b96add08b --- /dev/null +++ b/slack_bolt/workflows/step/utilities/fail.py @@ -0,0 +1,37 @@ +from slack_sdk.web import WebClient + + +class Fail: + """`fail()` utility to tell Slack the execution failure of a step from app. + + def execute(step, complete, fail): + inputs = step["inputs"] + # if something went wrong + error = {"message": "Just testing step failure!"} + fail(error=error) + + ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + This utility is a thin wrapper of workflows.stepFailed API method. + Refer to https://api.slack.com/methods/workflows.stepFailed for details. + """ + + def __init__(self, *, client: WebClient, body: dict): + self.client = client + self.body = body + + def __call__( + self, + *, + error: dict, + ) -> None: + self.client.workflows_stepFailed( + workflow_step_execute_id=self.body["event"]["workflow_step"]["workflow_step_execute_id"], + error=error, + ) diff --git a/slack_bolt/workflows/step/utilities/update.py b/slack_bolt/workflows/step/utilities/update.py new file mode 100644 index 000000000..bfc81d9d3 --- /dev/null +++ b/slack_bolt/workflows/step/utilities/update.py @@ -0,0 +1,52 @@ +from slack_sdk.web import WebClient + + +class Update: + """`update()` utility to tell Slack the processing results of a `save` listener. + + def save(ack, view, update): + ack() + + values = view["state"]["values"] + task_name = values["task_name_input"]["name"] + task_description = values["task_description_input"]["description"] + + inputs = { + "task_name": {"value": task_name["value"]}, + "task_description": {"value": task_description["value"]} + } + outputs = [ + { + "type": "text", + "name": "task_name", + "label": "Task name", + }, + { + "type": "text", + "name": "task_description", + "label": "Task description", + } + ] + update(inputs=inputs, outputs=outputs) + + ws = WorkflowStep( + callback_id="add_task", + edit=edit, + save=save, + execute=execute, + ) + app.step(ws) + + This utility is a thin wrapper of workflows.stepFailed API method. + Refer to https://api.slack.com/methods/workflows.updateStep for details. + """ + + def __init__(self, *, client: WebClient, body: dict): + self.client = client + self.body = body + + def __call__(self, **kwargs) -> None: + self.client.workflows_updateStep( + workflow_step_edit_id=self.body["workflow_step"]["workflow_step_edit_id"], + **kwargs, + ) diff --git a/tests/adapter_tests/__init__.py b/tests/adapter_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/asgi/__init__.py b/tests/adapter_tests/asgi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/asgi/test_asgi_http.py b/tests/adapter_tests/asgi/test_asgi_http.py new file mode 100644 index 000000000..f9f106461 --- /dev/null +++ b/tests/adapter_tests/asgi/test_asgi_http.py @@ -0,0 +1,294 @@ +import json +from urllib.parse import quote +from time import time +import pytest + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.asgi import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_asgi_server import AsgiTestServer, ENCODING +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsgiHttp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_raw_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return [ + (b"host", b"123.123.123"), + (b"user-agent", b"some slack thing"), + (b"content-length", bytes(str(len(body)), ENCODING)), + (b"accept", b"application/json,*/*"), + (b"accept-encoding", b"gzip,deflate"), + (b"content-type", bytes(content_type, ENCODING)), + (b"x-forwarded-for", b"123.123.123"), + (b"x-forwarded-proto", b"https"), + (b"x-slack-request-timestamp", bytes(timestamp, ENCODING)), + (b"x-slack-signature", bytes(self.generate_signature(body, timestamp), ENCODING)), + ] + + @pytest.mark.asyncio + async def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + body = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + body_data = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + headers = self.build_raw_headers(str(int(time())), "") + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + response = await asgi_server.http("GET", headers, "", "/slack/install") + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.body + + @pytest.mark.asyncio + async def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body_data = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + response = await asgi_server.http( + "POST", + headers, + body, + ) + + assert response.status_code == 200 + 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( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body = "" + headers = self.build_raw_headers(str(int(time())), "") + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + response = await asgi_server.http("PUT", headers, body) + + assert response.status_code == 404 + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/asgi/test_asgi_lifespan.py b/tests/adapter_tests/asgi/test_asgi_lifespan.py new file mode 100644 index 000000000..5b48f6b8a --- /dev/null +++ b/tests/adapter_tests/asgi/test_asgi_lifespan.py @@ -0,0 +1,93 @@ +import pytest + +from asgiref.testing import ApplicationCommunicator +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.asgi import SlackRequestHandler +from slack_bolt.app import App +from tests.mock_asgi_server import AsgiTestServer +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 TestAsgiLifespan: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + @pytest.mark.asyncio + async def test_startup(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + + response = await asgi_server.lifespan("startup") + + assert response.type == "lifespan.startup.complete" + assert response.message == "" + + @pytest.mark.asyncio + async def test_shutdown(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + + response = await asgi_server.lifespan("shutdown") + + 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( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + asgi_server = AsgiTestServer(SlackRequestHandler(app)) + + with pytest.raises(TypeError) as e: + await asgi_server.websocket() + + assert e.match("Unsupported scope type: 'websocket'") diff --git a/tests/adapter_tests/aws/__init__.py b/tests/adapter_tests/aws/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/aws/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py new file mode 100644 index 000000000..4a5744333 --- /dev/null +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -0,0 +1,404 @@ +import json +import os +from time import time +from typing import Dict, Any +from urllib.parse import quote +from unittest import mock + +from chalice import Chalice, Response +from chalice.app import Request +from chalice.config import Config +from chalice.local import LocalGateway +from chalice.test import Client +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.aws_lambda.chalice_handler import ( + ChaliceSlackRequestHandler, + not_found, +) + +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + assert_received_request_count, + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAwsChalice: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_not_found(self): + response = not_found() + assert response.status_code == 404 + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.event("app_mention") + def event_handler(): + pass + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut("test-shortcut") + def shortcut_handler(ack): + ack() + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.command("/hello-world") + def command_handler(ack): + ack() + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + def test_lazy_listeners(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + headers = self.build_headers(timestamp, body) + headers["x-slack-bolt-lazy-only"] = "1" + headers["x-slack-bolt-lazy-function-name"] = "say_it" + + request: Request = Request( + { + "requestContext": { + "httpMethod": "NONE", + "resourcePath": "/slack/events", + }, + "multiValueQueryStringParameters": {}, + "pathParameters": {}, + "context": {}, + "stageVariables": None, + "isBase64Encoded": False, + "body": body, + "headers": headers, + } + ) + response: Response = slack_handler.handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, "/chat.postMessage", 1) + + def test_lazy_listeners_cli(self): + with mock.patch.dict(os.environ, {"AWS_CHALICE_CLI_MODE": "true"}): + assert os.environ.get("AWS_CHALICE_CLI_MODE") == "true" + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + headers = self.build_headers(timestamp, body) + client = Client(chalice_app) + response = client.http.post("/slack/events", headers=headers, body=body) + + assert response.status_code == 200, f"Failed request: {response.body}" + assert_auth_test_count(self, 1) + assert_received_request_count(self, "/chat.postMessage", 1) + + @mock.patch( + "slack_bolt.adapter.aws_lambda.chalice_lazy_listener_runner.boto3", + autospec=True, + ) + def test_lazy_listeners_non_cli(self, mock_boto3): + with mock.patch.dict(os.environ, {"AWS_CHALICE_CLI_MODE": "false"}): + assert os.environ.get("AWS_CHALICE_CLI_MODE") == "false" + + mock_lambda = mock.MagicMock() # mock of boto3.client('lambda') + mock_boto3.client.return_value = mock_lambda + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + chalice_app = Chalice(app_name="bolt-python-chalice") + + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route( + "/slack/events", + methods=["POST"], + content_types=["application/x-www-form-urlencoded", "application/json"], + ) + def events() -> Response: + return slack_handler.handle(chalice_app.current_request) + + headers = self.build_headers(timestamp, body) + client = Client(chalice_app) + response = client.http.post("/slack/events", headers=headers, body=body) + assert response + assert mock_lambda.invoke.called + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + chalice_app = Chalice(app_name="bolt-python-chalice") + slack_handler = ChaliceSlackRequestHandler(app=app, chalice=chalice_app) + + @chalice_app.route("/slack/install", methods=["GET"]) + def install() -> Response: + return slack_handler.handle(chalice_app.current_request) + + response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( + method="GET", path="/slack/install", body="", headers={} + ) + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") diff --git a/tests/adapter_tests/aws/test_aws_lambda.py b/tests/adapter_tests/aws/test_aws_lambda.py new file mode 100644 index 000000000..c2e888fb7 --- /dev/null +++ b/tests/adapter_tests/aws/test_aws_lambda.py @@ -0,0 +1,365 @@ +import json +from time import time +from urllib.parse import quote +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from slack_sdk.oauth import OAuthStateStore + +from slack_bolt.adapter.aws_lambda import SlackRequestHandler +from slack_bolt.adapter.aws_lambda.handler import not_found +from slack_bolt.adapter.aws_lambda.internals import _first_value +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + assert_received_request_count, + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +try: + from moto import mock_aws +except ImportError: + from moto import mock_lambda as mock_aws + + +class LambdaContext: + function_name: str + + def __init__(self, function_name: str): + self.function_name = function_name + self.invoked_function_arn = f"arn:aws:lambda:us-east-1:account-id:function:{self.function_name}" + + +class TestAWSLambda: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + context = LambdaContext(function_name="test-function") + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": [content_type], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_not_found(self): + response = not_found() + assert response["statusCode"] == 404 + + def test_first_value(self): + assert _first_value({"foo": [1, 2, 3]}, "foo") == 1 + assert _first_value({"foo": []}, "foo") is None + assert _first_value({}, "foo") is None + + @mock_aws + def test_clear_all_log_handlers(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + handler = SlackRequestHandler(app) + handler.clear_all_log_handlers() + + @mock_aws + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"http": {"method": "POST"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"httpMethod": "POST"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + @mock_aws + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"http": {"method": "POST"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"httpMethod": "POST"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + @mock_aws + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"http": {"method": "POST"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + event = { + "body": body, + "queryStringParameters": {}, + "headers": self.build_headers(timestamp, body), + "requestContext": {"httpMethod": "POST"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + + @mock_aws + def test_lazy_listeners(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + def say_it(say): + say("Done!") + + app.command("/hello-world")(ack=command_handler, lazy=[say_it]) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + headers = self.build_headers(timestamp, body) + headers["x-slack-bolt-lazy-only"] = "1" + headers["x-slack-bolt-lazy-function-name"] = "say_it" + event = { + "body": body, + "queryStringParameters": {}, + "headers": headers, + "requestContext": {"http": {"method": "NONE"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, "/chat.postMessage", 1) + + @mock_aws + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + event = { + "body": "", + "queryStringParameters": {}, + "headers": {}, + "requestContext": {"http": {"method": "GET"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert response.get("body") is not None + + event = { + "body": "", + "queryStringParameters": {}, + "headers": {}, + "requestContext": {"httpMethod": "GET"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") + + @mock_aws + def test_oauth_redirect(self): + class TestStateStore(OAuthStateStore): + def consume(self, state: str) -> bool: + return state == "uuid4-value" + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=TestStateStore(), + ), + ) + + event = { + "body": "", + "queryStringParameters": {"code": "1234567890", "state": "uuid4-value"}, + "headers": {}, + "cookies": ["slack-app-oauth-state=uuid4-value"], + "requestContext": {"http": {"method": "GET"}}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert response.get("body") is not None + + event = { + "body": "", + "queryStringParameters": {"code": "1234567890", "state": "uuid4-value"}, + "headers": {}, + "multiValueHeaders": {"Cookie": ["slack-app-oauth-state=uuid4-value"]}, + "requestContext": {"httpMethod": "GET"}, + "isBase64Encoded": False, + } + response = SlackRequestHandler(app).handle(event, self.context) + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert response.get("body") is not None diff --git a/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py b/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py new file mode 100644 index 000000000..5f79b64ab --- /dev/null +++ b/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py @@ -0,0 +1,31 @@ +from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.utils import remove_os_env_temporarily, restore_os_env + +try: + from moto import mock_aws +except ImportError: + from moto import mock_s3 as mock_aws + + +class TestLambdaS3OAuthFlow: + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + restore_os_env(self.old_os_env) + + @mock_aws + def test_instantiation(self): + oauth_flow = LambdaS3OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write"], + ), + installation_bucket_name="dummy-installation", + oauth_state_bucket_name="dummy-state", + ) + assert oauth_flow is not None + assert oauth_flow.client is not None + assert oauth_flow.logger is not None diff --git a/tests/adapter_tests/bottle/__init__.py b/tests/adapter_tests/bottle/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/bottle/test_bottle.py b/tests/adapter_tests/bottle/test_bottle.py new file mode 100644 index 000000000..bd37fcc74 --- /dev/null +++ b/tests/adapter_tests/bottle/test_bottle.py @@ -0,0 +1,171 @@ +import json +from time import time +from typing import Optional +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.bottle import SlackRequestHandler +from slack_bolt.app import App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +signing_secret = "secret" +valid_token = "xoxb-valid" +mock_api_server_base_url = "http://localhost:8888" + + +def event_handler(): + pass + + +def shortcut_handler(ack): + ack() + + +def command_handler(ack): + ack() + + +from bottle import post, request, response +from boddle import boddle + + +@post("/slack/events") +def slack_events(): + return TestBottle.handler.handle(request, response) + + +class TestBottle: + signature_verifier = SignatureVerifier(signing_secret) + handler: Optional[SlackRequestHandler] = None + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + app = App( + client=web_client, + signing_secret=signing_secret, + ) + TestBottle.handler = SlackRequestHandler(app) + app.event("app_mention")(event_handler) + app.shortcut("test-shortcut")(shortcut_handler) + app.command("/hello-world")(command_handler) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + with boddle( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ): + response_body = slack_events() + assert response.status_code == 200 + assert response_body == "" + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + with boddle( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ): + response_body = slack_events() + assert response.status_code == 200 + assert response_body == "" + assert_auth_test_count(self, 1) + + def test_commands(self): + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + with boddle( + method="POST", + path="/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ): + response_body = slack_events() + assert response.status_code == 200 + assert response_body == "" + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/bottle/test_bottle_oauth.py b/tests/adapter_tests/bottle/test_bottle_oauth.py new file mode 100644 index 000000000..52ff7a2ae --- /dev/null +++ b/tests/adapter_tests/bottle/test_bottle_oauth.py @@ -0,0 +1,31 @@ +from slack_bolt.adapter.bottle import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings + +signing_secret = "secret" +app = App( + signing_secret=signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), +) +handler = SlackRequestHandler(app) + +from bottle import get, request, response +from boddle import boddle + + +@get("/slack/install") +def install(): + return handler.handle(request, response) + + +class TestBottle: + def test_oauth(self): + with boddle(method="GET", path="/slack/install"): + response_body = install() + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response_body diff --git a/tests/adapter_tests/cherrypy/__init__.py b/tests/adapter_tests/cherrypy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/cherrypy/test_cherrypy.py b/tests/adapter_tests/cherrypy/test_cherrypy.py new file mode 100644 index 000000000..f21845911 --- /dev/null +++ b/tests/adapter_tests/cherrypy/test_cherrypy.py @@ -0,0 +1,167 @@ +import json +from time import time +from urllib.parse import quote + +import cherrypy +from cherrypy.test import helper +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.cherrypy import SlackRequestHandler +from slack_bolt.app import App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestCherryPy(helper.CPWebCase): + helper.CPWebCase.interactive = False + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + @classmethod + def setup_server(cls): + cls.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(cls) + + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + app = App( + client=web_client, + signing_secret=signing_secret, + ) + + def event_handler(): + pass + + def shortcut_handler(ack): + ack() + + def command_handler(ack): + ack() + + app.event("app_mention")(event_handler) + app.shortcut("test-shortcut")(shortcut_handler) + app.command("/hello-world")(command_handler) + + handler = SlackRequestHandler(app) + + class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def events(self, **kwargs): + return handler.handle() + + cherrypy.tree.mount(SlackApp(), "/slack") + + @classmethod + def teardown_class(cls): + cls.supervisor.stop() + cleanup_mock_web_api_server(cls) + restore_os_env(cls.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return [ + ("content-length", str(len(body))), + ("x-slack-signature", self.generate_signature(body, timestamp)), + ("x-slack-request-timestamp", timestamp), + ] + + def test_events(self): + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + cherrypy.request.process_request_body = True + self.getPage( + "/slack/events", + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + self.assertStatus("200 OK") + self.assertBody("") + + def test_shortcuts(self): + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + cherrypy.request.process_request_body = True + self.getPage( + "/slack/events", + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + self.assertStatus("200 OK") + self.assertBody("") + + def test_commands(self): + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + cherrypy.request.process_request_body = True + self.getPage( + "/slack/events", + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + self.assertStatus("200 OK") + self.assertBody("") diff --git a/tests/adapter_tests/cherrypy/test_cherrypy_oauth.py b/tests/adapter_tests/cherrypy/test_cherrypy_oauth.py new file mode 100644 index 000000000..80053331f --- /dev/null +++ b/tests/adapter_tests/cherrypy/test_cherrypy_oauth.py @@ -0,0 +1,55 @@ +import cherrypy +from cherrypy.test import helper +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.cherrypy import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestCherryPy(helper.CPWebCase): + helper.CPWebCase.interactive = False + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + @classmethod + def setup_server(cls): + cls.old_os_env = remove_os_env_temporarily() + + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + app = App( + client=web_client, + signing_secret=signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + handler = SlackRequestHandler(app) + + class SlackApp(object): + @cherrypy.expose + @cherrypy.tools.slack_in() + def install(self, **kwargs): + return handler.handle() + + cherrypy.tree.mount(SlackApp(), "/slack") + + @classmethod + def teardown_class(cls): + cls.supervisor.stop() + restore_os_env(cls.old_os_env) + + def test_oauth(self): + cherrypy.request.process_request_body = False + self.getPage("/slack/install", method="GET") + self.assertStatus("200 OK") diff --git a/tests/adapter_tests/django/__init__.py b/tests/adapter_tests/django/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/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 new file mode 100644 index 000000000..f31a46411 --- /dev/null +++ b/tests/adapter_tests/django/test_django.py @@ -0,0 +1,267 @@ +import json +from time import time +from urllib.parse import quote + +from django.test import TestCase +from django.test.client import RequestFactory +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.django import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestDjango(TestCase): + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + rf = RequestFactory() + + def setUp(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def tearDown(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + request = self.rf.post("/slack/events", data=body, content_type="application/json") + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands_process_before_response(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands_lazy(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + def lazy_handler(): + pass + + app.command("/hello-world")(ack=command_handler, lazy=[lazy_handler]) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + request = self.rf.post( + "/slack/events", + data=body, + content_type="application/x-www-form-urlencoded", + ) + request.headers = self.build_headers(timestamp, body) + + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + request = self.rf.get("/slack/install") + response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert response.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.content.decode("utf-8") diff --git a/tests/adapter_tests/django/test_django_settings.py b/tests/adapter_tests/django/test_django_settings.py new file mode 100644 index 000000000..95eecbdd5 --- /dev/null +++ b/tests/adapter_tests/django/test_django_settings.py @@ -0,0 +1,10 @@ +SECRET_KEY = "XXX" +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "logs/db.sqlite3", + } +} +# Django 4 warning: The default value of USE_TZ will change from False to True in Django 5.0. +# Set USE_TZ to False in your project settings if you want to keep the current default behavior. +USE_TZ = False diff --git a/tests/adapter_tests/falcon/__init__.py b/tests/adapter_tests/falcon/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/falcon/test_falcon.py b/tests/adapter_tests/falcon/test_falcon.py new file mode 100644 index 000000000..4a2f34d36 --- /dev/null +++ b/tests/adapter_tests/falcon/test_falcon.py @@ -0,0 +1,221 @@ +import json +from time import time +from urllib.parse import quote + +import falcon + + +from falcon import testing +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.falcon import SlackAppResource +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +def new_falcon_app(): + if falcon.version.__version__.startswith("2."): + return falcon.API() + else: + return falcon.App() + + +class TestFalcon: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = new_falcon_app() + resource = SlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = new_falcon_app() + resource = SlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + api = new_falcon_app() + resource = SlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = new_falcon_app() + resource = SlackAppResource(app) + api.add_route("/slack/install", resource) + + client = testing.TestClient(api) + 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/flask/__init__.py b/tests/adapter_tests/flask/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/flask/test_flask.py b/tests/adapter_tests/flask/test_flask.py new file mode 100644 index 000000000..2ecac487f --- /dev/null +++ b/tests/adapter_tests/flask/test_flask.py @@ -0,0 +1,238 @@ +import json +from time import time +from urllib.parse import quote + +from flask import Flask, request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.flask import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestFlask: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": [content_type], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + flask_app = Flask(__name__) + + @flask_app.route("/slack/install", methods=["GET"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.get("/slack/install") + assert rv.headers.get("content-type") == "text/html; charset=utf-8" + assert rv.status_code == 200 + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + input = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + flask_app = Flask(__name__) + + @flask_app.route("/slack/events", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/google_cloud_functions/__init__.py b/tests/adapter_tests/google_cloud_functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py b/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py new file mode 100644 index 000000000..9dca9e332 --- /dev/null +++ b/tests/adapter_tests/google_cloud_functions/test_google_cloud_functions.py @@ -0,0 +1,238 @@ +import json +from time import time +from urllib.parse import quote + +from flask import Flask, request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestGoogleCloudFunctions: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": [content_type], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.get("/function") + assert rv.headers.get("content-type") == "text/html; charset=utf-8" + assert rv.status_code == 200 + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + input = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["POST"]) + def endpoint(): + return SlackRequestHandler(app).handle(request) + + with flask_app.test_client() as client: + rv = client.post( + "/function", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert rv.status_code == 200 + assert rv.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/pyramid/__init__.py b/tests/adapter_tests/pyramid/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/pyramid/test_pyramid.py b/tests/adapter_tests/pyramid/test_pyramid.py new file mode 100644 index 000000000..a0858a4e7 --- /dev/null +++ b/tests/adapter_tests/pyramid/test_pyramid.py @@ -0,0 +1,189 @@ +import json +from time import time +from unittest import TestCase +from urllib.parse import quote + +from pyramid import testing +from pyramid.request import Request +from pyramid.response import Response +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.pyramid import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestPyramid(TestCase): + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setUp(self): + self.config = testing.setUp() + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def tearDown(self): + testing.tearDown() + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": [content_type], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + request: Request = testing.DummyRequest() + request.path = "/slack/events" + request.method = "POST" + request.body = body.encode("utf-8") + request.headers = self.build_headers(timestamp, body) + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + request: Request = testing.DummyRequest() + request.path = "/slack/events" + request.method = "POST" + request.body = body.encode("utf-8") + request.headers = self.build_headers(timestamp, body) + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + request: Request = testing.DummyRequest() + request.path = "/slack/events" + request.method = "POST" + request.body = body.encode("utf-8") + request.headers = self.build_headers(timestamp, body) + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + request: Request = testing.DummyRequest() + request.path = "/slack/install" + request.method = "GET" + response: Response = SlackRequestHandler(app).handle(request) + assert response.status_code == 200 + assert "https://slack.com/oauth/v2/authorize?state=" in response.body.decode("utf-8") diff --git a/tests/adapter_tests/socket_mode/__init__.py b/tests/adapter_tests/socket_mode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py new file mode 100644 index 000000000..f59999192 --- /dev/null +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -0,0 +1,107 @@ +import asyncio +import logging +import threading +import time +from unittest import TestCase +from urllib.error import URLError +from urllib.request import urlopen + +from aiohttp import WSMsgType, web + +socket_mode_envelopes = [ + """{"envelope_id":"57d6a792-4d35-4d0b-b6aa-3361493e1caf","payload":{"type":"shortcut","token":"xxx","action_ts":"1610198080.300836","team":{"id":"T111","domain":"seratch"},"user":{"id":"U111","username":"seratch","team_id":"T111"},"is_enterprise_install":false,"enterprise":null,"callback_id":"do-something","trigger_id":"111.222.xxx"},"type":"interactive","accepts_response_payload":false}""", + """{"envelope_id":"1d3c79ab-0ffb-41f3-a080-d19e85f53649","payload":{"token":"xxx","team_id":"T111","team_domain":"xxx","channel_id":"C111","channel_name":"random","user_id":"U111","user_name":"seratch","command":"/hello-socket-mode","text":"","api_app_id":"A111","response_url":"https://hooks.slack.com/commands/T111/111/xxx","trigger_id":"111.222.xxx"},"type":"slash_commands","accepts_response_payload":true}""", + """{"envelope_id":"08cfc559-d933-402e-a5c1-79e135afaae4","payload":{"token":"xxx","team_id":"T111","api_app_id":"A111","event":{"client_msg_id":"c9b466b5-845c-49c6-a371-57ae44359bf1","type":"message","text":"<@W111>","user":"U111","ts":"1610197986.000300","team":"T111","blocks":[{"type":"rich_text","block_id":"1HBPc","elements":[{"type":"rich_text_section","elements":[{"type":"user","user_id":"U111"}]}]}],"channel":"C111","event_ts":"1610197986.000300","channel_type":"channel"},"type":"event_callback","event_id":"Ev111","event_time":1610197986,"authorizations":[{"enterprise_id":null,"team_id":"T111","user_id":"U111","is_bot":true,"is_enterprise_install":false}],"is_ext_shared_channel":false,"event_context":"1-message-T111-C111"},"type":"events_api","accepts_response_payload":false,"retry_attempt":1,"retry_reason":"timeout"}""", +] + + +def start_thread_socket_mode_server(test: TestCase, port: int): + logger = logging.getLogger(__name__) + state = {} + + def reset_server_state(): + state.update( + envelopes_to_consume=list(socket_mode_envelopes), + ) + + test.reset_server_state = reset_server_state + + async def health(request: web.Request): + wr = web.Response() + wr.set_status(200) + await wr.prepare(request) + return wr + + async def link(request: web.Request): + ws = web.WebSocketResponse() + await ws.prepare(request) + + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue + + if state["envelopes_to_consume"]: + e = state["envelopes_to_consume"].pop(0) + logger.debug(f"Send an envelope: {e}") + await ws.send_str(e) + + message = msg.data + logger.debug(f"Server received a message: {message}") + + await ws.send_str(message) + + return ws + + app = web.Application() + app.add_routes( + [ + web.get("/link", link), + web.get("/health", health), + ] + ) + runner = web.AppRunner(app) + + def run_server(): + reset_server_state() + + test.loop = asyncio.new_event_loop() + asyncio.set_event_loop(test.loop) + test.loop.run_until_complete(runner.setup()) + site = web.TCPSite(runner, "127.0.0.1", port, reuse_port=True) + test.loop.run_until_complete(site.start()) + + # run until it's stopped from the main thread + test.loop.run_forever() + + test.loop.run_until_complete(runner.cleanup()) + test.loop.close() + + return run_server + + +def start_socket_mode_server(test, port: int): + test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port)) + test.sm_thread.daemon = True + test.sm_thread.start() + wait_for_socket_mode_server(port, 4) + + +def wait_for_socket_mode_server(port: int, timeout: int): + start_time = time.time() + while (time.time() - start_time) < timeout: + try: + urlopen(f"http://127.0.0.1:{port}/health") + return + except URLError: + time.sleep(0.01) + + +def stop_socket_mode_server(test: TestCase): + # An event loop runs in a thread and executes all callbacks and Tasks in + # its thread. While a Task is running in the event loop, no other Tasks + # can run in the same thread. When a Task executes an await expression, the + # running Task gets suspended, and the event loop executes the next Task. + # To schedule a callback from another OS thread, the loop.call_soon_threadsafe() method should be used. + # https://docs.python.org/3/library/asyncio-dev.html#asyncio-multithreading + test.loop.call_soon_threadsafe(test.loop.stop) + test.sm_thread.join(timeout=5) diff --git a/tests/adapter_tests/socket_mode/mock_web_api_server.py b/tests/adapter_tests/socket_mode/mock_web_api_server.py new file mode 100644 index 000000000..22136e633 --- /dev/null +++ b/tests/adapter_tests/socket_mode/mock_web_api_server.py @@ -0,0 +1,168 @@ +import json +import logging +import re +import threading +import time +from http import HTTPStatus +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import Type +from unittest import TestCase +from urllib.parse import urlparse, parse_qs + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + + pattern_for_language = re.compile("python/(\\S+)", re.IGNORECASE) + pattern_for_package_identifier = re.compile("slackclient/(\\S+)") + + def is_valid_user_agent(self): + user_agent = self.headers["User-Agent"] + return self.pattern_for_language.search(user_agent) and self.pattern_for_package_identifier.search(user_agent) + + def is_valid_token(self): + if self.path.startswith("oauth"): + return True + return "Authorization" in self.headers and ( + str(self.headers["Authorization"]).startswith("Bearer xoxb-") + or str(self.headers["Authorization"]).startswith("Bearer xapp-") + ) + + def set_common_headers(self): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("connection", "close") + self.end_headers() + + invalid_auth = { + "ok": False, + "error": "invalid_auth", + } + + not_found = { + "ok": False, + "error": "test_data_not_found", + } + + def _handle(self): + try: + if self.path == "/received_requests.json": + self.send_response(200) + self.set_common_headers() + self.wfile.write(json.dumps(self.received_requests).encode("utf-8")) + return + + if self.is_valid_token() and self.is_valid_user_agent(): + parsed_path = urlparse(self.path) + + len_header = self.headers.get("Content-Length") or 0 + content_len = int(len_header) + post_body = self.rfile.read(content_len) + request_body = None + if post_body: + try: + post_body = post_body.decode("utf-8") + if post_body.startswith("{"): + request_body = json.loads(post_body) + else: + request_body = {k: v[0] for k, v in parse_qs(post_body).items()} + except UnicodeDecodeError: + pass + else: + if parsed_path and parsed_path.query: + request_body = {k: v[0] for k, v in parse_qs(parsed_path.query).items()} + + body = {"ok": False, "error": "internal_error"} + if self.path == "/auth.test": + body = { + "ok": True, + "url": "https://xyz.slack.com/", + "team": "Testing Workspace", + "user": "bot-user", + "team_id": "T111", + "user_id": "W11", + "bot_id": "B111", + "enterprise_id": "E111", + "is_enterprise_install": False, + } + if self.path == "/apps.connections.open": + body = { + "ok": True, + "url": "ws://localhost:3011/link/?ticket=xxx&app_id=yyy", + } + if self.path == "/api.test" and request_body: + body = {"ok": True, "args": request_body} + else: + body = self.invalid_auth + + if not body: + body = self.not_found + + self.send_response(HTTPStatus.OK) + self.set_common_headers() + self.wfile.write(json.dumps(body).encode("utf-8")) + self.wfile.close() + + except Exception as e: + self.logger.error(str(e), exc_info=True) + raise + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + +class MockServerThread(threading.Thread): + def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler): + threading.Thread.__init__(self) + self.handler = handler + self.test = test + + def run(self): + self.server = HTTPServer(("localhost", 8888), self.handler) + self.test.server_url = "http://localhost:8888" + self.test.host, self.test.port = self.server.socket.getsockname() + self.test.server_started.set() # threading.Event() + + self.test = None + try: + self.server.serve_forever() + finally: + self.server.server_close() + + def stop(self): + self.server.shutdown() + self.join() + + +def setup_mock_web_api_server(test: TestCase): + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + test.server_started.wait() + + +def cleanup_mock_web_api_server(test: TestCase): + test.thread.stop() + test.thread = None + + +def assert_auth_test_count(test: TestCase, expected_count: int): + time.sleep(0.1) + retry_count = 0 + error = None + while retry_count < 3: + try: + test.mock_received_requests["/auth.test"] == expected_count + break + except Exception as e: + error = e + retry_count += 1 + # waiting for mock_received_requests updates + time.sleep(0.1) + + if error is not None: + raise error diff --git a/tests/adapter_tests/socket_mode/test_interactions_builtin.py b/tests/adapter_tests/socket_mode/test_interactions_builtin.py new file mode 100644 index 000000000..aec04d938 --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_interactions_builtin.py @@ -0,0 +1,78 @@ +import logging +import time + +from slack_sdk import WebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from .mock_socket_mode_server import ( + start_socket_mode_server, + stop_socket_mode_server, +) +from .mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from ...utils import remove_os_env_temporarily, restore_os_env + + +class TestSocketModeBuiltin: + logger = logging.getLogger(__name__) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + self.web_client = WebClient( + token="xoxb-api_test", + base_url="http://localhost:8888", + ) + start_socket_mode_server(self, 3011) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + stop_socket_mode_server(self) + + def test_interactions(self): + app = App(client=self.web_client) + + result = {"shortcut": False, "command": False, "message": False} + + @app.shortcut("do-something") + def shortcut_handler(ack): + result["shortcut"] = True + ack() + + @app.command("/hello-socket-mode") + def command_handler(ack): + result["command"] = True + ack() + + @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, + trace_enabled=True, + ) + try: + handler.client.ping_pong_trace_enabled = True + handler.client.wss_uri = "ws://127.0.0.1:3011/link" + + handler.connect() + assert handler.client.is_connected() is True + time.sleep(2) # wait for the message receiver + + handler.client.send_message("foo") + + time.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + 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 new file mode 100644 index 000000000..90e027f9d --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py @@ -0,0 +1,79 @@ +import logging +import time + +from slack_sdk import WebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.websocket_client import SocketModeHandler +from .mock_socket_mode_server import ( + start_socket_mode_server, + stop_socket_mode_server, +) +from .mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from ...utils import remove_os_env_temporarily, restore_os_env + + +class TestSocketModeWebsocketClient: + logger = logging.getLogger(__name__) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + self.web_client = WebClient( + token="xoxb-api_test", + base_url="http://localhost:8888", + ) + start_socket_mode_server(self, 3012) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + stop_socket_mode_server(self) + + def test_interactions(self): + + app = App(client=self.web_client) + + result = {"shortcut": False, "command": False, "message": False} + + @app.shortcut("do-something") + def shortcut_handler(ack): + result["shortcut"] = True + ack() + + @app.command("/hello-socket-mode") + def command_handler(ack): + result["command"] = True + ack() + + @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, + trace_enabled=True, + ) + try: + handler.client.wss_uri = "ws://localhost:3012/link" + handler.client.default_auto_reconnect_enabled = False + + handler.connect() + time.sleep(2) # wait for the message receiver + assert handler.client.is_connected() is True + + handler.client.send_message("foo") + + 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/socket_mode/test_lazy_listeners.py b/tests/adapter_tests/socket_mode/test_lazy_listeners.py new file mode 100644 index 000000000..5acd288f4 --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_lazy_listeners.py @@ -0,0 +1,78 @@ +import logging +import time + +from slack_sdk import WebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from .mock_socket_mode_server import ( + start_socket_mode_server, + stop_socket_mode_server, +) +from .mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from ...utils import remove_os_env_temporarily, restore_os_env + + +class TestSocketModeLazyListeners: + logger = logging.getLogger(__name__) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + self.web_client = WebClient( + token="xoxb-api_test", + base_url="http://localhost:8888", + ) + start_socket_mode_server(self, 3011) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + stop_socket_mode_server(self) + + def test_lazy_listener_calls(self): + + app = App(client=self.web_client) + + result = {"lazy_called": False} + + @app.shortcut("do-something") + def handle_shortcuts(ack): + ack() + + @app.event("message") + def handle_message_events(body, logger): + logger.info(body) + + def lazy_func(body): + assert body.get("command") == "/hello-socket-mode" + result["lazy_called"] = True + + app.command("/hello-socket-mode")( + ack=lambda ack: ack(), + lazy=[lazy_func], + ) + + handler = SocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + trace_enabled=True, + ) + try: + handler.client.wss_uri = "ws://127.0.0.1:3011/link" + handler.connect() + assert handler.client.is_connected() is True + time.sleep(2) # wait for the message receiver + handler.client.send_message("foo") + + spent_time = 0 + while spent_time < 5 and result["lazy_called"] is False: + spent_time += 0.5 + time.sleep(0.5) + assert result["lazy_called"] is True + + finally: + handler.client.close() diff --git a/tests/adapter_tests/starlette/__init__.py b/tests/adapter_tests/starlette/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/starlette/test_fastapi.py b/tests/adapter_tests/starlette/test_fastapi.py new file mode 100644 index 000000000..f91b9897e --- /dev/null +++ b/tests/adapter_tests/starlette/test_fastapi.py @@ -0,0 +1,261 @@ +import json +from time import time +from urllib.parse import quote + +from fastapi import FastAPI, Depends +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from starlette.requests import Request +from starlette.testclient import TestClient + +from slack_bolt.adapter.fastapi import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestFastAPI: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = FastAPI() + app_handler = SlackRequestHandler(app) + + @api.get("/slack/install") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + client.follow_redirects = False + response = client.get("/slack/install") + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text + + def test_custom_props(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack, context): + assert context.get("foo") == "FOO" + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = FastAPI() + app_handler = SlackRequestHandler(app) + + def get_foo(): + yield "FOO" + + @api.post("/slack/events") + async def endpoint(req: Request, foo: str = Depends(get_foo)): + return await app_handler.handle(req, {"foo": foo}) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/starlette/test_starlette.py b/tests/adapter_tests/starlette/test_starlette.py new file mode 100644 index 000000000..18066a9d2 --- /dev/null +++ b/tests/adapter_tests/starlette/test_starlette.py @@ -0,0 +1,222 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route +from starlette.testclient import TestClient + +from slack_bolt.adapter.starlette import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestStarlette: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + app_handler = SlackRequestHandler(app) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + app_handler = SlackRequestHandler(app) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + app_handler = SlackRequestHandler(app) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + app_handler = SlackRequestHandler(app) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/install", endpoint=endpoint, methods=["GET"])], + ) + client = TestClient(api) + client.follow_redirects = False + response = client.get("/slack/install") + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests/tornado/__init__.py b/tests/adapter_tests/tornado/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/tornado/test_tornado.py b/tests/adapter_tests/tornado/test_tornado.py new file mode 100644 index 000000000..e6b77ad91 --- /dev/null +++ b/tests/adapter_tests/tornado/test_tornado.py @@ -0,0 +1,170 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from tornado.httpclient import HTTPRequest, HTTPResponse +from tornado.testing import AsyncHTTPTestCase, gen_test +from tornado.web import Application + +from slack_bolt.adapter.tornado import SlackEventsHandler +from slack_bolt.app import App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +signing_secret = "secret" +valid_token = "xoxb-valid" +mock_api_server_base_url = "http://localhost:8888" + + +def event_handler(): + pass + + +def shortcut_handler(ack): + ack() + + +def command_handler(ack): + ack() + + +class TestTornado(AsyncHTTPTestCase): + signature_verifier = SignatureVerifier(signing_secret) + + def setUp(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + self.app = App( + client=web_client, + signing_secret=signing_secret, + ) + self.app.event("app_mention")(event_handler) + self.app.shortcut("test-shortcut")(shortcut_handler) + self.app.command("/hello-world")(command_handler) + + AsyncHTTPTestCase.setUp(self) + + def tearDown(self): + AsyncHTTPTestCase.tearDown(self) + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def get_app(self): + return Application([("/slack/events", SlackEventsHandler, dict(app=self.app))]) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + @gen_test + async def test_events(self): + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert_auth_test_count(self, 1) + + @gen_test + async def test_shortcuts(self): + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert_auth_test_count(self, 1) + + @gen_test + async def test_commands(self): + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/tornado/test_tornado_oauth.py b/tests/adapter_tests/tornado/test_tornado_oauth.py new file mode 100644 index 000000000..a18081572 --- /dev/null +++ b/tests/adapter_tests/tornado/test_tornado_oauth.py @@ -0,0 +1,41 @@ +from tornado.httpclient import HTTPRequest, HTTPResponse, HTTPClientError +from tornado.testing import AsyncHTTPTestCase, gen_test +from tornado.web import Application + +from slack_bolt.adapter.tornado import SlackOAuthHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.utils import remove_os_env_temporarily, restore_os_env + +signing_secret = "secret" + +app = App( + signing_secret=signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), +) + + +class TestTornado(AsyncHTTPTestCase): + def get_app(self): + return Application([("/slack/install", SlackOAuthHandler, dict(app=app))]) + + def setUp(self): + AsyncHTTPTestCase.setUp(self) + self.old_os_env = remove_os_env_temporarily() + + def tearDown(self): + AsyncHTTPTestCase.tearDown(self) + restore_os_env(self.old_os_env) + + @gen_test + async def test_oauth(self): + request = HTTPRequest(url=self.get_url("/slack/install"), method="GET", follow_redirects=False) + try: + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + except HTTPClientError as e: + assert e.code == 200 diff --git a/tests/adapter_tests/wsgi/__init__.py b/tests/adapter_tests/wsgi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/wsgi/test_wsgi_http.py b/tests/adapter_tests/wsgi/test_wsgi_http.py new file mode 100644 index 000000000..c7fca2e25 --- /dev/null +++ b/tests/adapter_tests/wsgi/test_wsgi_http.py @@ -0,0 +1,271 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.wsgi import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.mock_wsgi_server import WsgiTestServer +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWsgiHttp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_raw_headers(self, timestamp: str, body: str = ""): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "host": "123.123.123", + "user-agent": "some slack thing", + "content-length": str(len(body)), + "accept": "application/json,*/*", + "accept-encoding": "gzip,deflate", + "content-type": content_type, + "x-forwarded-for": "123.123.123", + "x-forwarded-proto": "https", + "x-slack-request-timestamp": timestamp, + "x-slack-signature": self.generate_signature(body, timestamp), + } + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + body = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_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, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + body_data = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + headers = self.build_raw_headers(str(int(time()))) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="GET", headers=headers, path="/slack/install") + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.body + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body_data = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http( + method="POST", + headers=headers, + body=body, + ) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_unsupported_method(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body = "" + headers = self.build_raw_headers(str(int(time())), "") + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="PUT", headers=headers, body=body) + + assert response.status == "404 Not Found" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests_async/__init__.py b/tests/adapter_tests_async/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests_async/socket_mode/__init__.py b/tests/adapter_tests_async/socket_mode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py new file mode 100644 index 000000000..812806a8c --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -0,0 +1,80 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler +from slack_bolt.app.async_app import AsyncApp +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env +from ...adapter_tests.socket_mode.mock_socket_mode_server import ( + start_socket_mode_server, + stop_socket_mode_server, +) + + +class TestSocketModeAiohttp: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_events(self): + start_socket_mode_server(self, 3021) + + app = AsyncApp(client=self.web_client) + + result = {"shortcut": False, "command": False, "message": False} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + result["shortcut"] = True + await ack() + + @app.command("/hello-socket-mode") + async def command_handler(ack): + result["command"] = True + await ack() + + @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, + ) + try: + handler.client.wss_uri = "ws://localhost:3021/link" + + await handler.connect_async() + await asyncio.sleep(2) # wait for the message receiver + + await handler.client.send_message("foo") + + await asyncio.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + 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_lazy_listeners.py b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py new file mode 100644 index 000000000..9144bd239 --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_lazy_listeners.py @@ -0,0 +1,81 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler +from slack_bolt.app.async_app import AsyncApp +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env +from ...adapter_tests.socket_mode.mock_socket_mode_server import ( + start_socket_mode_server, + stop_socket_mode_server, +) + + +class TestSocketModeAiohttp: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_lazy_listeners(self): + start_socket_mode_server(self, 3021) + + app = AsyncApp(client=self.web_client) + + result = {"lazy_called": False} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + await ack() + + @app.event("message") + async def handle_message_events(body, logger): + logger.info(body) + + async def just_ack(ack): + await ack() + + async def lazy_func(body): + assert body.get("command") == "/hello-socket-mode" + result["lazy_called"] = True + + app.command("/hello-socket-mode")(ack=just_ack, lazy=[lazy_func]) + + handler = AsyncSocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + ) + try: + handler.client.wss_uri = "ws://localhost:3021/link" + + await handler.connect_async() + await asyncio.sleep(2) # wait for the message receiver + await handler.client.send_message("foo") + + spent_time = 0 + while spent_time < 5 and result["lazy_called"] is False: + spent_time += 0.5 + await asyncio.sleep(0.5) + assert result["lazy_called"] is True + + finally: + await handler.client.close() + 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 new file mode 100644 index 000000000..fc27150ee --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -0,0 +1,80 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.socket_mode.websockets import AsyncSocketModeHandler +from slack_bolt.app.async_app import AsyncApp +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env +from ...adapter_tests.socket_mode.mock_socket_mode_server import ( + start_socket_mode_server, + stop_socket_mode_server, +) + + +class TestSocketModeWebsockets: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_events(self): + start_socket_mode_server(self, 3022) + + app = AsyncApp(client=self.web_client) + + result = {"shortcut": False, "command": False, "message": False} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + result["shortcut"] = True + await ack() + + @app.command("/hello-socket-mode") + async def command_handler(ack): + result["command"] = True + await ack() + + @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, + ) + try: + handler.client.wss_uri = "ws://localhost:3022/link" + + await handler.connect_async() + await asyncio.sleep(2) # wait for the message receiver + + await handler.client.send_message("foo") + + await asyncio.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + assert result["message"] is True + finally: + await handler.client.close() + stop_socket_mode_server(self) diff --git a/tests/adapter_tests_async/test_async_asgi.py b/tests/adapter_tests_async/test_async_asgi.py new file mode 100644 index 000000000..d17cf069b --- /dev/null +++ b/tests/adapter_tests_async/test_async_asgi.py @@ -0,0 +1,224 @@ +import json +from urllib.parse import quote +from time import time +import pytest + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.asgi.async_handler import AsyncSlackRequestHandler +from slack_bolt.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_asgi_server import AsgiTestServer, ENCODING +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncAsgi: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_raw_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return [ + (b"host", b"123.123.123"), + (b"user-agent", b"some slack thing"), + (b"content-length", bytes(str(len(body)), ENCODING)), + (b"accept", b"application/json,*/*"), + (b"accept-encoding", b"gzip,deflate"), + (b"content-type", bytes(content_type, ENCODING)), + (b"x-forwarded-for", b"123.123.123"), + (b"x-forwarded-proto", b"https"), + (b"x-slack-request-timestamp", bytes(timestamp, ENCODING)), + (b"x-slack-signature", bytes(self.generate_signature(body, timestamp), ENCODING)), + ] + + @pytest.mark.asyncio + async def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + 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(AsyncSlackRequestHandler(app)) + + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(AsyncSlackRequestHandler(app)) + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + body_data = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(AsyncSlackRequestHandler(app)) + response = await asgi_server.http("POST", headers, body) + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + headers = self.build_raw_headers(str(int(time())), "") + + asgi_server = AsgiTestServer(AsyncSlackRequestHandler(app)) + response = await asgi_server.http("GET", headers, "", "/slack/install") + + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.body + + @pytest.mark.asyncio + async def test_url_verification(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body_data = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + asgi_server = AsgiTestServer(AsyncSlackRequestHandler(app)) + response = await asgi_server.http( + "POST", + headers, + body, + ) + + assert response.status_code == 200 + assert response.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 0) diff --git a/tests/adapter_tests_async/test_async_falcon.py b/tests/adapter_tests_async/test_async_falcon.py new file mode 100644 index 000000000..d7a8c277a --- /dev/null +++ b/tests/adapter_tests_async/test_async_falcon.py @@ -0,0 +1,225 @@ +import json +from time import time +from urllib.parse import quote + +import pytest +import falcon +from falcon.testing import ASGIConductor + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.falcon.async_resource import AsyncSlackAppResource +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +def new_falcon_app(): + return falcon.asgi.App() + + +class TestAsyncFalcon: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + @pytest.mark.asyncio + async def test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + 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) + + @pytest.mark.asyncio + async def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + 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) + + @pytest.mark.asyncio + async def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + 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) + + @pytest.mark.asyncio + async def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/install", resource) + + 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 new file mode 100644 index 000000000..e0175d3fa --- /dev/null +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -0,0 +1,262 @@ +import json +from time import time +from urllib.parse import quote + +from fastapi import FastAPI, Depends +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient +from starlette.requests import Request +from starlette.testclient import TestClient + +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestFastAPI: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + @api.get("/slack/install") + async def endpoint(req: Request): + return await app_handler.handle(req) + + client = TestClient(api) + client.follow_redirects = False + response = client.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 + + def test_custom_props(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack, context): + assert context.get("foo") == "FOO" + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = FastAPI() + app_handler = AsyncSlackRequestHandler(app) + + def get_foo(): + yield "FOO" + + @api.post("/slack/events") + async def endpoint(req: Request, foo: str = Depends(get_foo)): + return await app_handler.handle(req, {"foo": foo}) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests_async/test_async_sanic.py b/tests/adapter_tests_async/test_async_sanic.py new file mode 100644 index 000000000..9a948e3a6 --- /dev/null +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -0,0 +1,226 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from sanic import Sanic +from sanic.request import Request +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.sanic.async_handler import AsyncSlackRequestHandler +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestSanic: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @staticmethod + def unique_sanic_app_name() -> str: + return f"awesome-slack-app-{str(time()).replace('.', '-')}" + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + @pytest.mark.asyncio + async def test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = Sanic(name=self.unique_sanic_app_name()) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = await api.asgi_client.post( + url="/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = Sanic(name=self.unique_sanic_app_name()) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = await api.asgi_client.post( + url="/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + api = Sanic(name=self.unique_sanic_app_name()) + app_handler = AsyncSlackRequestHandler(app) + + @api.post("/slack/events") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = await api.asgi_client.post( + url="/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = Sanic(name=self.unique_sanic_app_name()) + app_handler = AsyncSlackRequestHandler(app) + + @api.get("/slack/install") + async def endpoint(req: Request): + return await app_handler.handle(req) + + _, response = await api.asgi_client.get(url="/slack/install") + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("set-cookie") is not None + assert response.headers.get("set-cookie").endswith("; Path=/; Max-Age=600; SameSite=Lax; Secure; HttpOnly") is True + + # NOTE: Although sanic-testing 0.6 does not have this value, + # Sanic apps properly generate the content-length header + # assert response.headers.get("content-length") == "607" + + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests_async/test_async_starlette.py b/tests/adapter_tests_async/test_async_starlette.py new file mode 100644 index 000000000..849c75168 --- /dev/null +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -0,0 +1,224 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route +from starlette.testclient import TestClient + +from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncStarlette: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + app_handler = AsyncSlackRequestHandler(app) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + app_handler = AsyncSlackRequestHandler(app) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/events", endpoint=endpoint, methods=["POST"])], + ) + app_handler = AsyncSlackRequestHandler(app) + + client = TestClient(api) + response = client.post( + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + app_handler = AsyncSlackRequestHandler(app) + + async def endpoint(req: Request): + return await app_handler.handle(req) + + api = Starlette( + debug=True, + routes=[Route("/slack/install", endpoint=endpoint, methods=["GET"])], + ) + + client = TestClient(api) + client.follow_redirects = False + response = client.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 diff --git a/tests/adapter_tests_async/test_tornado.py b/tests/adapter_tests_async/test_tornado.py new file mode 100644 index 000000000..05cb04819 --- /dev/null +++ b/tests/adapter_tests_async/test_tornado.py @@ -0,0 +1,170 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient +from tornado.httpclient import HTTPRequest, HTTPResponse +from tornado.testing import AsyncHTTPTestCase, gen_test +from tornado.web import Application + +from slack_bolt.adapter.tornado.async_handler import AsyncSlackEventsHandler +from slack_bolt.async_app import AsyncApp +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +signing_secret = "secret" +valid_token = "xoxb-valid" +mock_api_server_base_url = "http://localhost:8888" + + +async def event_handler(): + pass + + +async def shortcut_handler(ack): + await ack() + + +async def command_handler(ack): + await ack() + + +class TestTornado(AsyncHTTPTestCase): + signature_verifier = SignatureVerifier(signing_secret) + + def setUp(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + self.app = AsyncApp( + client=web_client, + signing_secret=signing_secret, + ) + self.app.event("app_mention")(event_handler) + self.app.shortcut("test-shortcut")(shortcut_handler) + self.app.command("/hello-world")(command_handler) + + AsyncHTTPTestCase.setUp(self) + + def tearDown(self): + AsyncHTTPTestCase.tearDown(self) + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def get_app(self): + return Application([("/slack/events", AsyncSlackEventsHandler, dict(app=self.app))]) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + @gen_test + async def test_events(self): + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert_auth_test_count(self, 1) + + @gen_test + async def test_shortcuts(self): + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert_auth_test_count(self, 1) + + @gen_test + async def test_commands(self): + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + request = HTTPRequest( + url=self.get_url("/slack/events"), + method="POST", + body=body, + headers=self.build_headers(timestamp, body), + ) + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests_async/test_tornado_oauth.py b/tests/adapter_tests_async/test_tornado_oauth.py new file mode 100644 index 000000000..254183460 --- /dev/null +++ b/tests/adapter_tests_async/test_tornado_oauth.py @@ -0,0 +1,41 @@ +from tornado.httpclient import HTTPRequest, HTTPResponse, HTTPClientError +from tornado.testing import AsyncHTTPTestCase, gen_test +from tornado.web import Application + +from slack_bolt.adapter.tornado.async_handler import AsyncSlackOAuthHandler +from slack_bolt.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.utils import remove_os_env_temporarily, restore_os_env + +signing_secret = "secret" + +app = AsyncApp( + signing_secret=signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), +) + + +class TestTornado(AsyncHTTPTestCase): + def get_app(self): + return Application([("/slack/install", AsyncSlackOAuthHandler, dict(app=app))]) + + def setUp(self): + AsyncHTTPTestCase.setUp(self) + self.old_os_env = remove_os_env_temporarily() + + def tearDown(self): + AsyncHTTPTestCase.tearDown(self) + restore_os_env(self.old_os_env) + + @gen_test + async def test_oauth(self): + request = HTTPRequest(url=self.get_url("/slack/install"), method="GET", follow_redirects=False) + try: + response: HTTPResponse = await self.http_client.fetch(request) + assert response.code == 200 + except HTTPClientError as e: + assert e.code == 200 diff --git a/tests/async_scenario_tests/test_app.py b/tests/async_scenario_tests/test_app.py deleted file mode 100644 index 418cfb26d..000000000 --- a/tests/async_scenario_tests/test_app.py +++ /dev/null @@ -1,80 +0,0 @@ -import pytest -from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore - -from slack_bolt.async_app import AsyncApp -from slack_bolt.error import BoltError -from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow -from tests.utils import remove_os_env_temporarily, restore_os_env - - -class TestAsyncApp: - def setup_method(self): - self.old_os_env = remove_os_env_temporarily() - - def teardown_method(self): - restore_os_env(self.old_os_env) - - def test_signing_secret_absence(self): - with pytest.raises(BoltError): - AsyncApp(signing_secret=None, token="xoxb-xxx") - with pytest.raises(BoltError): - AsyncApp(signing_secret="", token="xoxb-xxx") - - def non_coro_func(self, ack): - ack() - - def test_non_coroutine_func_listener(self): - app = AsyncApp(signing_secret="valid", token="xoxb-xxx") - with pytest.raises(BoltError): - app.action("a")(self.non_coro_func) - - async def simple_listener(self, ack): - await ack() - - def test_listener_registration_error(self): - app = AsyncApp(signing_secret="valid", token="xoxb-xxx") - with pytest.raises(BoltError): - app.action({"type": "invalid_type", "action_id": "a"})(self.simple_listener) - - # -------------------------- - # single team auth - # -------------------------- - - def test_valid_single_auth(self): - app = AsyncApp(signing_secret="valid", token="xoxb-xxx") - assert app != None - - def test_token_absence(self): - with pytest.raises(BoltError): - AsyncApp(signing_secret="valid", token=None) - with pytest.raises(BoltError): - AsyncApp(signing_secret="valid", token="") - - # -------------------------- - # multi teams auth - # -------------------------- - - def test_valid_multi_auth(self): - app = AsyncApp( - signing_secret="valid", client_id="111.222", client_secret="valid" - ) - assert app != None - - def test_valid_multi_auth_oauth_flow(self): - oauth_flow = AsyncOAuthFlow( - client_id="111.222", - client_secret="valid", - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), - ) - app = AsyncApp(signing_secret="valid", oauth_flow=oauth_flow) - assert app != None - - def test_valid_multi_auth_client_id_absence(self): - with pytest.raises(BoltError): - AsyncApp(signing_secret="valid", client_id=None, client_secret="valid") - - def test_valid_multi_auth_secret_absence(self): - with pytest.raises(BoltError): - AsyncApp(signing_secret="valid", client_id="111.222", client_secret=None) diff --git a/tests/async_scenario_tests/test_events.py b/tests/async_scenario_tests/test_events.py deleted file mode 100644 index aeee7e313..000000000 --- a/tests/async_scenario_tests/test_events.py +++ /dev/null @@ -1,153 +0,0 @@ -import asyncio -import json -from random import random -from time import time - -import pytest - -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) -from tests.utils import remove_os_env_temporarily, restore_os_env - - -class TestAsyncEvents: - signing_secret = "secret" - valid_token = "xoxb-valid" - mock_api_server_base_url = "http://localhost:8888" - signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) - - @pytest.fixture - def event_loop(self): - old_os_env = remove_os_env_temporarily() - try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) - finally: - restore_os_env(old_os_env) - - def generate_signature(self, body: str, timestamp: str): - return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, - ) - - def build_headers(self, timestamp: str, body: str): - return { - "content-type": ["application/json"], - "x-slack-signature": [self.generate_signature(body, timestamp)], - "x-slack-request-timestamp": [timestamp], - } - - def build_valid_app_mention_request(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(app_mention_payload) - return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) - - @pytest.mark.asyncio - async def test_mock_server_is_running(self): - resp = await self.web_client.api_test() - assert resp != None - - @pytest.mark.asyncio - async def test_app_mention(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.event("app_mention")(whats_up) - - request = self.build_valid_app_mention_request() - response = await app.async_dispatch(request) - assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - await asyncio.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 - - @pytest.mark.asyncio - async def test_process_before_response(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - process_before_response=True, - ) - app.event("app_mention")(whats_up) - - request = self.build_valid_app_mention_request() - response = await app.async_dispatch(request) - assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - # no sleep here - assert self.mock_received_requests["/chat.postMessage"] == 1 - - @pytest.mark.asyncio - async def test_middleware_skip(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) - app.event("app_mention", middleware=[skip_middleware])(whats_up) - - request = self.build_valid_app_mention_request() - response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 - - @pytest.mark.asyncio - async def test_simultaneous_requests(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.event("app_mention")(random_sleeper) - - request = self.build_valid_app_mention_request() - - times = 10 - tasks = [] - for i in range(times): - tasks.append(asyncio.ensure_future(app.async_dispatch(request))) - - await asyncio.sleep(5) - # Verifies all the tasks have been completed with 200 OK - assert sum([t.result().status for t in tasks if t.done()]) == 200 * times - - assert self.mock_received_requests["/auth.test"] == times - assert self.mock_received_requests["/chat.postMessage"] == times - - -app_mention_payload = { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": { - "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", - "type": "app_mention", - "text": "<@W111> Hi there!", - "user": "W222", - "ts": "1595926230.009600", - "team": "T111", - "channel": "C111", - "event_ts": "1595926230.009600", - }, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1595926230, - "authed_users": ["W111"], -} - - -async def random_sleeper(payload, say): - assert payload == app_mention_payload - seconds = random() + 2 # 2-3 seconds - await asyncio.sleep(seconds) - await say(f"Sending this message after sleeping for {seconds} seconds") - - -async def whats_up(payload, say): - assert payload == app_mention_payload - await say("What's up?") - - -async def skip_middleware(req, resp, next): - # return next() - pass diff --git a/tests/async_scenario_tests/test_message.py b/tests/async_scenario_tests/test_message.py deleted file mode 100644 index d1c971cd0..000000000 --- a/tests/async_scenario_tests/test_message.py +++ /dev/null @@ -1,136 +0,0 @@ -import asyncio -import json -import re -from time import time - -import pytest - -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) -from tests.utils import remove_os_env_temporarily, restore_os_env - - -class TestAsyncMessage: - signing_secret = "secret" - valid_token = "xoxb-valid" - mock_api_server_base_url = "http://localhost:8888" - signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) - - @pytest.fixture - def event_loop(self): - old_os_env = remove_os_env_temporarily() - try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) - finally: - restore_os_env(old_os_env) - - def generate_signature(self, body: str, timestamp: str): - return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, - ) - - def build_headers(self, timestamp: str, body: str): - return { - "content-type": ["application/json"], - "x-slack-signature": [self.generate_signature(body, timestamp)], - "x-slack-request-timestamp": [timestamp], - } - - def build_request(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(message_payload) - return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) - - @pytest.mark.asyncio - async def test_string_keyword(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.message("Hello")(whats_up) - - request = self.build_request() - response = await app.async_dispatch(request) - assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - await asyncio.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 - - @pytest.mark.asyncio - async def test_string_keyword_unmatched(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.message("HELLO")(whats_up) - - request = self.build_request() - response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 - - @pytest.mark.asyncio - async def test_regexp_keyword(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.message(re.compile("He.lo"))(whats_up) - - request = self.build_request() - response = await app.async_dispatch(request) - assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - await asyncio.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 - - @pytest.mark.asyncio - async def test_regexp_keyword_unmatched(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.message(re.compile("HELLO"))(whats_up) - - request = self.build_request() - response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 - - -message_payload = { - "token": "verification_token", - "team_id": "T111", - "enterprise_id": "E111", - "api_app_id": "A111", - "event": { - "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", - "type": "message", - "text": "Hello World!", - "user": "W111", - "ts": "1596183880.004200", - "team": "T111", - "blocks": [ - { - "type": "rich_text", - "block_id": "ezJ", - "elements": [ - { - "type": "rich_text_section", - "elements": [{"type": "text", "text": "Hello World!"}], - } - ], - } - ], - "channel": "C111", - "event_ts": "1596183880.004200", - "channel_type": "channel", - }, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1596183880, - "authed_users": ["W111"], -} - - -async def whats_up(payload, say): - assert payload == message_payload - await say("What's up?") diff --git a/tests/async_scenario_tests/test_view_submission.py b/tests/async_scenario_tests/test_view_submission.py deleted file mode 100644 index bcfbfe93d..000000000 --- a/tests/async_scenario_tests/test_view_submission.py +++ /dev/null @@ -1,179 +0,0 @@ -import asyncio -import json -from time import time -from urllib.parse import quote - -import pytest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient - -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.request.async_request import AsyncBoltRequest -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) -from tests.utils import remove_os_env_temporarily, restore_os_env - - -class TestAsyncViewSubmission: - signing_secret = "secret" - valid_token = "xoxb-valid" - mock_api_server_base_url = "http://localhost:8888" - signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) - - @pytest.fixture - def event_loop(self): - old_os_env = remove_os_env_temporarily() - try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) - finally: - restore_os_env(old_os_env) - - def generate_signature(self, body: str, timestamp: str): - return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, - ) - - def build_headers(self, timestamp: str, body: str): - return { - "content-type": ["application/x-www-form-urlencoded"], - "x-slack-signature": [self.generate_signature(body, timestamp)], - "x-slack-request-timestamp": [timestamp], - } - - def build_valid_request(self) -> AsyncBoltRequest: - timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) - - @pytest.mark.asyncio - async def test_mock_server_is_running(self): - resp = await self.web_client.api_test() - assert resp != None - - @pytest.mark.asyncio - async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.view("view-id")(simple_listener) - - request = self.build_valid_request() - response = await app.async_dispatch(request) - assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - - @pytest.mark.asyncio - async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.view_submission("view-id")(simple_listener) - - request = self.build_valid_request() - response = await app.async_dispatch(request) - assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - - @pytest.mark.asyncio - async def test_process_before_response(self): - app = AsyncApp( - client=self.web_client, - signing_secret=self.signing_secret, - process_before_response=True, - ) - app.view("view-id")(simple_listener) - - request = self.build_valid_request() - response = await app.async_dispatch(request) - assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - - @pytest.mark.asyncio - async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - request = self.build_valid_request() - response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 - - app.view("view-idddd")(simple_listener) - response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 - - @pytest.mark.asyncio - async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - request = self.build_valid_request() - response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 - - app.view_submission("view-idddd")(simple_listener) - response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 - - -payload = { - "type": "view_submission", - "team": { - "id": "T111", - "domain": "workspace-domain", - "enterprise_id": "E111", - "enterprise_name": "Sandbox Org", - }, - "user": { - "id": "W111", - "username": "primary-owner", - "name": "primary-owner", - "team_id": "T111", - }, - "api_app_id": "A111", - "token": "verification_token", - "trigger_id": "111.222.valid", - "view": { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "input", - "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, - "optional": False, - "element": {"type": "plain_text_input", "action_id": "maBWU"}, - } - ], - "private_metadata": "This is for you!", - "callback_id": "view-id", - "state": { - "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} - }, - "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, - "clear_on_close": False, - "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "previous_view_id": None, - "root_view_id": "V111", - "app_id": "A111", - "external_id": "", - "app_installed_team_id": "T111", - "bot_id": "B111", - }, - "response_urls": [], -} - -raw_body = f"payload={quote(json.dumps(payload))}" - - -async def simple_listener(ack, body): - assert body["trigger_id"] == "111.222.valid" - assert body["view"]["private_metadata"] == "This is for you!" - await ack() diff --git a/tests/mock_asgi_server.py b/tests/mock_asgi_server.py new file mode 100644 index 000000000..0b29a3e5a --- /dev/null +++ b/tests/mock_asgi_server.py @@ -0,0 +1,132 @@ +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, + 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) -> str: + return self._body.decode(ENCODING) + + @property + 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, type: str, message: str = ""): + self.type = type + self.message = message + + +class AsgiTestServer: + def __init__( + self, + asgi_app: BaseSlackRequestHandler, + root_path: str = "", + scheme: str = "http", + asgi: dict = {"version": "3.0", "spec_version": "2.3"}, + server: Tuple[str, int] = ("127.0.0.1", 4000), + ): + self.asgi_app = asgi_app + self.server_scope = {"root_path": root_path, "scheme": scheme, "asgi": asgi, "server": server} + + async def http( + self, + method: str, + headers: Iterable[Tuple[bytes, bytes]], + body: str, + path: str = "/slack/events", + query_string: bytes = b"", + http_version: str = "1.1", + client: Tuple[str, int] = ("127.0.0.1", 60000), + ) -> AsgiTestServerResponse: + scope = dict( + self.server_scope, + **{ + "type": "http", + "method": method, + "headers": headers, + "path": path, + "raw_path": bytes(path, ENCODING), + "query_string": query_string, + "http_version": http_version, + "client": client, + }, + ) + + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({"type": "http.request", "body": bytes(body, ENCODING), "more_body": False}) + + response_start = await communicator.receive_output(timeout=1) + response_body = await communicator.receive_output(timeout=1) + + 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 + https://asgi.readthedocs.io/en/latest/specs/lifespan.html + + Args: + event (str): the lifespan type ex: "startup" for "lifespan.startup" + """ + scope = dict( + self.server_scope, + **{ + "type": "lifespan", + }, + ) + + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({"type": f"lifespan.{event}"}) + + result = await communicator.receive_output(timeout=1) + + # Send shutdown so the handler exits cleanly + if event == "startup": + await communicator.send_input({"type": "lifespan.shutdown"}) + await communicator.receive_output(timeout=1) + + return AsgiTestServerLifespanResponse( + type=result["type"], + message=result.get("message", ""), + ) + + async def websocket(self) -> None: + """This is not implemented""" + scope = dict( + self.server_scope, + **{ + "type": "websocket", + }, + ) + + communicator = ApplicationCommunicator(self.asgi_app, scope) + await communicator.send_input({}) + await communicator.receive_output(timeout=1) diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py deleted file mode 100644 index 601de9156..000000000 --- a/tests/mock_web_api_server.py +++ /dev/null @@ -1,124 +0,0 @@ -import json -import logging -import threading -from http import HTTPStatus -from http.server import HTTPServer, SimpleHTTPRequestHandler -from typing import Type -from unittest import TestCase -from urllib.parse import urlparse, parse_qs - - -class MockHandler(SimpleHTTPRequestHandler): - protocol_version = "HTTP/1.1" - default_request_version = "HTTP/1.1" - logger = logging.getLogger(__name__) - received_requests = {} - - def is_valid_token(self): - return "Authorization" in self.headers and str( - self.headers["Authorization"] - ).startswith("Bearer xoxb-") - - def set_common_headers(self): - self.send_header("content-type", "application/json;charset=utf-8") - self.send_header("connection", "close") - self.end_headers() - - invalid_auth = { - "ok": False, - "error": "invalid_auth", - } - - def _handle(self): - self.received_requests[self.path] = self.received_requests.get(self.path, 0) + 1 - try: - body = {"ok": True} - if self.is_valid_token(): - parsed_path = urlparse(self.path) - - len_header = self.headers.get("Content-Length") or 0 - content_len = int(len_header) - post_body = self.rfile.read(content_len) - request_body = None - if post_body: - try: - post_body = post_body.decode("utf-8") - if post_body.startswith("{"): - request_body = json.loads(post_body) - else: - request_body = { - k: v[0] for k, v in parse_qs(post_body).items() - } - except UnicodeDecodeError: - pass - else: - if parsed_path and parsed_path.query: - request_body = { - k: v[0] for k, v in parse_qs(parsed_path.query).items() - } - - self.logger.info(f"request body: {request_body}") - - header = self.headers["authorization"] - pattern = str(header).split("xoxb-", 1)[1] - if pattern.isnumeric(): - self.send_response(int(pattern)) - self.set_common_headers() - self.wfile.write("""{"ok":false}""".encode("utf-8")) - return - else: - body = self.invalid_auth - - self.send_response(HTTPStatus.OK) - self.set_common_headers() - self.wfile.write(json.dumps(body).encode("utf-8")) - self.wfile.close() - - except Exception as e: - self.logger.error(str(e), exc_info=True) - raise - - def do_GET(self): - self._handle() - - def do_POST(self): - self._handle() - - -class MockServerThread(threading.Thread): - def __init__( - self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler - ): - threading.Thread.__init__(self) - self.handler = handler - self.test = test - - def run(self): - self.server = HTTPServer(("localhost", 8888), self.handler) - self.test.mock_received_requests = self.handler.received_requests - self.test.server_url = "http://localhost:8888" - self.test.host, self.test._port = self.server.socket.getsockname() - self.test.server_started.set() # threading.Event() - - self.test = None - try: - self.server.serve_forever(0.05) - finally: - self.server.server_close() - - def stop(self): - self.handler.received_requests = {} - self.server.shutdown() - self.join() - - -def setup_mock_web_api_server(test: TestCase): - test.server_started = threading.Event() - test.thread = MockServerThread(test) - test.thread.start() - test.server_started.wait() - - -def cleanup_mock_web_api_server(test: TestCase): - test.thread.stop() - test.thread = None diff --git a/tests/mock_web_api_server/__init__.py b/tests/mock_web_api_server/__init__.py new file mode 100644 index 000000000..e164d4f4a --- /dev/null +++ b/tests/mock_web_api_server/__init__.py @@ -0,0 +1,85 @@ +import asyncio +import threading +import time +from queue import Queue +from unittest import TestCase + +from .mock_server_thread import MockServerThread +from .received_requests import ReceivedRequests + + +def setup_mock_web_api_server(test: TestCase): + test.server_started = threading.Event() + test.received_requests = ReceivedRequests(Queue()) + test.thread = MockServerThread(test.received_requests.queue, test) + test.thread.start() + test.server_started.wait() + + +def cleanup_mock_web_api_server(test: TestCase): + test.thread.stop() + test.thread = None + + +def assert_received_request_count(test: TestCase, path: str, min_count: int, timeout: float = 1): + start_time = time.time() + error = None + while time.time() - start_time < timeout: + try: + received_count = test.received_requests.get(path, 0) + assert ( + received_count == min_count + ), f"Expected {min_count} '{path}' {'requests' if min_count > 1 else 'request'}, but got {received_count}!" + return + except Exception as e: + error = e + # waiting for some requests to be received + time.sleep(0.05) + + if error is not None: + raise error + + +def assert_auth_test_count(test: TestCase, expected_count: int): + assert_received_request_count(test, "/auth.test", expected_count, 0.5) + + +######### +# async # +######### + + +def setup_mock_web_api_server_async(test: TestCase): + test.server_started = threading.Event() + test.received_requests = ReceivedRequests(asyncio.Queue()) + test.thread = MockServerThread(test.received_requests.queue, test) + test.thread.start() + test.server_started.wait() + + +def cleanup_mock_web_api_server_async(test: TestCase): + test.thread.stop_unsafe() + test.thread = None + + +async def assert_received_request_count_async(test: TestCase, path: str, min_count: int, timeout: float = 1): + start_time = time.time() + error = None + while time.time() - start_time < timeout: + try: + received_count = await test.received_requests.get_async(path, 0) + assert ( + received_count == min_count + ), f"Expected {min_count} '{path}' {'requests' if min_count > 1 else 'request'}, but got {received_count}!" + return + except Exception as e: + error = e + # waiting for mock_received_requests updates + await asyncio.sleep(0.05) + + if error is not None: + raise error + + +async def assert_auth_test_count_async(test: TestCase, expected_count: int): + await assert_received_request_count_async(test, "/auth.test", expected_count, 0.5) diff --git a/tests/mock_web_api_server/mock_handler.py b/tests/mock_web_api_server/mock_handler.py new file mode 100644 index 000000000..dec2305e5 --- /dev/null +++ b/tests/mock_web_api_server/mock_handler.py @@ -0,0 +1,204 @@ +import json +import logging +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler +from typing import Optional +from urllib.parse import ParseResult, parse_qs, urlparse + +INVALID_AUTH = json.dumps( + { + "ok": False, + "error": "invalid_auth", + } +) + +OK_FALSE_RESPONSE = json.dumps( + { + "ok": False, + } +) + +OAUTH_V2_ACCESS_RESPONSE = json.dumps( + { + "ok": True, + "access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "app_id": "A0KRD7HC3", + "team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"}, + "enterprise": {"name": "slack-sports", "id": "E12345678"}, + "authed_user": {"id": "U1234", "scope": "chat:write", "access_token": "xoxp-1234", "token_type": "user"}, + } +) + +OAUTH_V2_ACCESS_BOT_REFRESH_RESPONSE = json.dumps( + { + "ok": True, + "app_id": "A0KRD7HC3", + "access_token": "xoxb-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-bot-refreshed", + "token_type": "bot", + "scope": "chat:write,commands", + "bot_user_id": "U0KRQLJ9H", + "team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"}, + "enterprise": {"name": "slack-sports", "id": "E12345678"}, + } +) + +OAUTH_V2_ACCESS_USER_REFRESH_RESPONSE = json.dumps( + { + "ok": True, + "app_id": "A0KRD7HC3", + "access_token": "xoxp-valid-refreshed", + "expires_in": 43200, + "refresh_token": "xoxe-1-valid-user-refreshed", + "token_type": "user", + "scope": "search:read", + "team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"}, + "enterprise": {"name": "slack-sports", "id": "E12345678"}, + } +) +BOT_AUTH_TEST_RESPONSE = json.dumps( + { + "ok": True, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "bot", + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "bot_id": "BZYBOTHED", + } +) + +USER_AUTH_TEST_RESPONSE = json.dumps( + { + "ok": True, + "url": "https://subarachnoid.slack.com/", + "team": "Subarachnoid Workspace", + "user": "some-user", + "team_id": "T0G9PQBBK", + "user_id": "W99999", + } +) + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + + def is_valid_token(self): + return "Authorization" in self.headers and str(self.headers["Authorization"]).startswith("Bearer xoxb-") + + def is_valid_user_token(self): + return "Authorization" in self.headers and str(self.headers["Authorization"]).startswith("Bearer xoxp-") + + def set_common_headers(self, content_length: int = 0): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("content-length", str(content_length)) + self.end_headers() + + def _handle(self): + parsed_path: ParseResult = urlparse(self.path) + path = parsed_path.path + # put_nowait is common between Queue & asyncio.Queue, it does not need to be awaited + self.server.queue.put_nowait(path) + try: + if path == "/webhook": + self.send_response(200) + self.set_common_headers(len("OK")) + self.wfile.write("OK".encode("utf-8")) + return + + body = """{"ok": true}""" + if path == "/oauth.v2.access": + if self.headers.get("authorization") is not None: + request_body = self._parse_request_body( + parsed_path=parsed_path, + content_len=int(self.headers.get("Content-Length") or 0), + ) + self.logger.info(f"request body: {request_body}") + + if request_body.get("grant_type") == "refresh_token": + refresh_token = request_body.get("refresh_token") + if refresh_token is not None: + if "bot-valid" in refresh_token: + self.send_response(200) + self.set_common_headers(len(OAUTH_V2_ACCESS_BOT_REFRESH_RESPONSE)) + self.wfile.write(OAUTH_V2_ACCESS_BOT_REFRESH_RESPONSE.encode("utf-8")) + return + if "user-valid" in refresh_token: + self.send_response(200) + self.set_common_headers(len(OAUTH_V2_ACCESS_USER_REFRESH_RESPONSE)) + self.wfile.write(OAUTH_V2_ACCESS_USER_REFRESH_RESPONSE.encode("utf-8")) + return + elif request_body.get("code") is not None: + self.send_response(200) + self.set_common_headers(len(OAUTH_V2_ACCESS_RESPONSE)) + self.wfile.write(OAUTH_V2_ACCESS_RESPONSE.encode("utf-8")) + return + + if self.is_valid_user_token(): + if path == "/auth.test": + self.send_response(200) + self.send_header("x-oauth-scopes", "chat:write,search:read") + self.set_common_headers(len(USER_AUTH_TEST_RESPONSE)) + self.wfile.write(USER_AUTH_TEST_RESPONSE.encode("utf-8")) + return + + if self.is_valid_token(): + if path == "/auth.test": + self.send_response(200) + self.send_header("x-oauth-scopes", "chat:write,commands") + self.set_common_headers(len(BOT_AUTH_TEST_RESPONSE)) + self.wfile.write(BOT_AUTH_TEST_RESPONSE.encode("utf-8")) + return + + request_body = self._parse_request_body( + parsed_path=parsed_path, + content_len=int(self.headers.get("Content-Length") or 0), + ) + self.logger.info(f"request: {path} {request_body}") + + header = self.headers["authorization"] + pattern = str(header).split("xoxb-", 1)[1] + if pattern.isnumeric(): + self.send_response(int(pattern)) + self.set_common_headers(len(OK_FALSE_RESPONSE)) + self.wfile.write(OK_FALSE_RESPONSE.encode("utf-8")) + return + else: + body = INVALID_AUTH + + self.send_response(HTTPStatus.OK) + self.set_common_headers(len(body)) + self.wfile.write(body.encode("utf-8")) + + except Exception as e: + self.logger.error(str(e), exc_info=True) + raise + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + def _parse_request_body(self, parsed_path: str, content_len: int) -> Optional[dict]: + post_body = self.rfile.read(content_len) + request_body = None + if post_body: + try: + post_body = post_body.decode("utf-8") + if post_body.startswith("{"): + request_body = json.loads(post_body) + else: + request_body = {k: v[0] for k, v in parse_qs(post_body).items()} + except UnicodeDecodeError: + pass + else: + if parsed_path and parsed_path.query: + request_body = {k: v[0] for k, v in parse_qs(parsed_path.query).items()} + return request_body diff --git a/tests/mock_web_api_server/mock_server_thread.py b/tests/mock_web_api_server/mock_server_thread.py new file mode 100644 index 000000000..d3e047f03 --- /dev/null +++ b/tests/mock_web_api_server/mock_server_thread.py @@ -0,0 +1,42 @@ +import asyncio +import threading +from http.server import HTTPServer, SimpleHTTPRequestHandler +from queue import Queue +from typing import Type, Union +from unittest import TestCase + +from .mock_handler import MockHandler + + +class MockServerThread(threading.Thread): + def __init__( + self, queue: Union[Queue, asyncio.Queue], test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler + ): + threading.Thread.__init__(self) + self.handler = handler + self.test = test + self.queue = queue + + def run(self): + self.server = HTTPServer(("localhost", 8888), self.handler) + self.server.queue = self.queue + self.test.server_url = "http://localhost:8888" + self.test.host, self.test.port = self.server.socket.getsockname() + self.test.server_started.set() # threading.Event() + + self.test = None + try: + self.server.serve_forever(0.05) + finally: + self.server.server_close() + + def stop(self): + with self.server.queue.mutex: + del self.server.queue + self.server.shutdown() + self.join() + + def stop_unsafe(self): + del self.server.queue + self.server.shutdown() + self.join() diff --git a/tests/mock_web_api_server/received_requests.py b/tests/mock_web_api_server/received_requests.py new file mode 100644 index 000000000..ad959b5b4 --- /dev/null +++ b/tests/mock_web_api_server/received_requests.py @@ -0,0 +1,21 @@ +import asyncio +from queue import Queue +from typing import Optional, Union + + +class ReceivedRequests: + def __init__(self, queue: Union[Queue, asyncio.Queue]): + self.queue = queue + self.received_requests = {} + + def get(self, key: str, default: Optional[int] = None) -> Optional[int]: + while not self.queue.empty(): + path = self.queue.get() + self.received_requests[path] = self.received_requests.get(path, 0) + 1 + return self.received_requests.get(key, default) + + async def get_async(self, key: str, default: Optional[int] = None) -> Optional[int]: + while not self.queue.empty(): + path = await self.queue.get() + self.received_requests[path] = self.received_requests.get(path, 0) + 1 + return self.received_requests.get(key, default) diff --git a/tests/mock_wsgi_server.py b/tests/mock_wsgi_server.py new file mode 100644 index 000000000..7e8bad2e8 --- /dev/null +++ b/tests/mock_wsgi_server.py @@ -0,0 +1,141 @@ +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 + +ENCODING = "utf-8" + + +class WsgiTestServerResponse: + def __init__(self) -> None: + self.status: Optional[str] = None + 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) -> 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._stream = io.BytesIO(bytes(body, ENCODING)) + + def get_content_length(self) -> int: + 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 readlines(self, hint: int = -1) -> List[bytes]: + return self._stream.readlines(hint) + + def __iter__(self): + return iter(self._stream) + + +class WsgiTestServer: + def __init__( + self, + wsgi_app: SlackRequestHandler, + root_path: str = "", + input_terminated: bool = True, + 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 = 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, + method: str, + headers: Dict[str, str], + body: Optional[str] = None, + path: str = "/slack/events", + query_string: str = "", + server_protocol: str = "HTTP/1.1", + server_name: str = "0.0.0.0", + server_port: str = "3000", + script_name: str = "", + ) -> WsgiTestServerResponse: + environ = dict( + self.environ, + **{ + "REQUEST_METHOD": method, + "PATH_INFO": f"{self.root_path}{path}", + "QUERY_STRING": query_string, + "RAW_URI": f"{self.root_path}{path}?{query_string}", + "SERVER_PROTOCOL": server_protocol, + "SERVER_NAME": server_name, + "SERVER_PORT": server_port, + "SCRIPT_NAME": script_name, + }, + ) + for key, value in headers.items(): + header_key = key.upper().replace("-", "_") + if header_key in {"CONTENT_LENGTH", "CONTENT_TYPE"}: + environ[header_key] = value + else: + environ[f"HTTP_{header_key}"] = value + + if body is not None: + readable = MockReadable(body) + environ["wsgi.input"] = readable + if "CONTENT_LENGTH" not in environ: + environ["CONTENT_LENGTH"] = str(readable.get_content_length()) + else: + environ["wsgi.input"] = MockReadable("") + + response = WsgiTestServerResponse() + + def start_response( + status: str, + headers: List[Tuple[str, str]], + exc_info: Optional[Any] = None, + ) -> Callable[[bytes], object]: + response.status = status + response._headers = headers + 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 f075b696e..5cdff39bf 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -1,41 +1,80 @@ +import logging +import time +from concurrent.futures import Executor +import ssl + import pytest +from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -from slack_bolt import App +from slack_bolt import App, BoltContext, BoltRequest, Say +from slack_bolt.authorization import AuthorizeResult from slack_bolt.error import BoltError from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.utils import remove_os_env_temporarily, restore_os_env class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + def setup_method(self): self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) def teardown_method(self): + cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) - def test_signing_secret_absence(self): - with pytest.raises(BoltError): - App(signing_secret=None, token="xoxb-xxx") - with pytest.raises(BoltError): - App(signing_secret="", token="xoxb-xxx") + @staticmethod + def handle_app_mention(body, say: Say, payload, event): + assert body["event"] == payload + assert payload == event + say("What's up?") + + # -------------------------- + # basic tests + # -------------------------- def simple_listener(self, ack): ack() def test_listener_registration_error(self): - app = App(signing_secret="valid", token="xoxb-xxx") + app = App(signing_secret="valid", client=self.web_client) with pytest.raises(BoltError): app.action({"type": "invalid_type", "action_id": "a"})(self.simple_listener) + def test_listener_executor(self): + class TestExecutor(Executor): + """A executor that does nothing for testing""" + + pass + + executor = TestExecutor() + app = App( + signing_secret="valid", + client=self.web_client, + listener_executor=executor, + ) + + assert app.listener_runner.listener_executor == executor + assert app.listener_runner.lazy_listener_runner.executor == executor + # -------------------------- # single team auth # -------------------------- def test_valid_single_auth(self): - app = App(signing_secret="valid", token="xoxb-xxx") - assert app != None + app = App(signing_secret="valid", client=self.web_client) + assert app is not None def test_token_absence(self): with pytest.raises(BoltError): @@ -43,28 +82,271 @@ def test_token_absence(self): with pytest.raises(BoltError): App(signing_secret="valid", token="") + def test_token_verification_enabled_False(self): + App( + signing_secret="valid", + client=self.web_client, + token_verification_enabled=False, + ) + App( + signing_secret="valid", + token="xoxb-invalid", + token_verification_enabled=False, + ) + + assert self.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 # -------------------------- def test_valid_multi_auth(self): - app = App(signing_secret="valid", client_id="111.222", client_secret="valid") - assert app != None + app = App( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id="111.222", client_secret="valid"), + ) + assert app is not None def test_valid_multi_auth_oauth_flow(self): oauth_flow = OAuthFlow( - client_id="111.222", - client_secret="valid", - installation_store=FileInstallationStore(), - oauth_state_store=FileOAuthStateStore(expiration_seconds=120), + settings=OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) ) app = App(signing_secret="valid", oauth_flow=oauth_flow) - assert app != None + assert app is not None def test_valid_multi_auth_client_id_absence(self): with pytest.raises(BoltError): - App(signing_secret="valid", client_id=None, client_secret="valid") + App( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id=None, client_secret="valid"), + ) def test_valid_multi_auth_secret_absence(self): with pytest.raises(BoltError): - App(signing_secret="valid", client_id="111.222", client_secret=None) + App( + signing_secret="valid", + oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), + ) + + def test_authorize_conflicts(self): + oauth_settings = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + # no error with this + App(signing_secret="valid", oauth_settings=oauth_settings) + + def authorize() -> AuthorizeResult: + return AuthorizeResult(enterprise_id="E111", team_id="T111") + + with pytest.raises(BoltError): + App( + signing_secret="valid", + authorize=authorize, + oauth_settings=oauth_settings, + ) + + oauth_flow = OAuthFlow(settings=oauth_settings) + # no error with this + App(signing_secret="valid", oauth_flow=oauth_flow) + + with pytest.raises(BoltError): + App(signing_secret="valid", authorize=authorize, oauth_flow=oauth_flow) + + def test_installation_store_conflicts(self): + store1 = FileInstallationStore() + store2 = FileInstallationStore() + app = App( + signing_secret="valid", + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=store1, + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = App( + signing_secret="valid", + oauth_flow=OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=store1, + ) + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = App( + signing_secret="valid", + oauth_flow=OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="valid", + ) + ), + installation_store=store1, + ) + assert app.installation_store is store1 + + def test_none_body(self): + app = App(signing_secret="valid", client=self.web_client) + + req = BoltRequest(body=None, headers={}, mode="http") + response = app.dispatch(req) + # request verification failure + assert response.status == 401 + assert response.body == '{"error": "invalid request"}' + + req = BoltRequest(body=None, headers={}, mode="socket_mode") + response = app.dispatch(req) + # request verification is skipped for Socket Mode + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + def test_none_body_no_middleware(self): + app = App( + signing_secret="valid", + client=self.web_client, + ssl_check_enabled=False, + ignoring_self_events_enabled=False, + request_verification_enabled=False, + token_verification_enabled=False, + url_verification_enabled=False, + ) + req = BoltRequest(body=None, headers={}, mode="http") + response = app.dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + req = BoltRequest(body=None, headers={}, mode="socket_mode") + response = app.dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + def test_proxy_ssl_for_respond(self): + 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_context, + ) + app = App( + signing_secret="valid", + client=web_client, + authorize=lambda: AuthorizeResult( + enterprise_id="E111", + team_id="T111", + ), + ) + + result = {"called": False} + + @app.event("app_mention") + def handle(context: BoltContext, respond): + assert context.respond.proxy == "http://proxy-host:9000/" + assert context.respond.ssl == ssl_context + assert respond.proxy == "http://proxy-host:9000/" + assert respond.ssl == ssl_context + result["called"] = True + + req = BoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") + response = app.dispatch(req) + assert response.status == 200 + assert result["called"] is True + + def test_argument_logger_propagation(self): + custom_logger = logging.getLogger(f"{__name__}-{time.time()}-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = App( + signing_secret="valid", + client=WebClient( + token=self.valid_token, + base_url=self.mock_api_server_base_url, + ), + authorize=lambda: AuthorizeResult( + enterprise_id="E111", + team_id="T111", + ), + logger=custom_logger, + ) + result = {"called": False} + + def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + + @app.use + def global_middleware(logger, next): + _verify_logger(logger) + next() + + def listener_middleware(logger, next): + _verify_logger(logger) + next() + + def listener_matcher(logger): + _verify_logger(logger) + return True + + @app.event( + "app_mention", + middleware=[listener_middleware], + matchers=[listener_matcher], + ) + def handle(logger: logging.Logger): + _verify_logger(logger) + result["called"] = True + + req = BoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") + response = app.dispatch(req) + assert response.status == 200 + assert result["called"] is True + + +app_mention_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, +} diff --git a/tests/scenario_tests/test_app_actor_user_token.py b/tests/scenario_tests/test_app_actor_user_token.py new file mode 100644 index 000000000..024c29e59 --- /dev/null +++ b/tests/scenario_tests/test_app_actor_user_token.py @@ -0,0 +1,218 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltContext, BoltRequest, Say +from slack_bolt.oauth.oauth_settings import OAuthSettings +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.utils import remove_os_env_temporarily, restore_os_env + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if team_id == "T0G9PQBBK": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + installed_at=datetime.datetime.now().timestamp(), + ) + if team_id == "T014GJXU940" and enterprise_id == "E013Y3SHLAY": + return Installation( + app_id="A111", + enterprise_id="E013Y3SHLAY", + team_id="T014GJXU940", + user_id="W11111", + user_token="xoxp-valid-actor-based", + user_scopes=["search:read", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + return None + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, team_id: str = "T014GJXU940"): + timestamp, body = str(int(time())), json.dumps( + { + "team_id": team_id, + "enterprise_id": "E013Y3SHLAY", + "context_team_id": team_id, + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "W013QGS7BPF", + "display_as_bot": False, + "team": team_id, + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + } + ) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_authorize_result(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands", "chat:write"], + user_scopes=["search:read", "chat:write"], + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + def handle_events(context: BoltContext, say: Say): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T014GJXU940" + assert context.actor_user_id == "W013QGS7BPF" + + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid-actor-based" + assert context.authorize_result.user_scopes == ["search:read", "chat:write"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" + say("What's up?") + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_authorize_result_no_user_token(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands", "chat:write"], + user_scopes=["search:read", "chat:write"], + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + def handle_events(context: BoltContext, say: Say): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T111111" + assert context.actor_user_id == "W013QGS7BPF" + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user == "bot" + assert context.authorize_result.user_id is None + assert context.authorize_result.user_token is None + assert context.authorize_result.user_scopes is None + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" + say("What's up?") + + response = app.dispatch(self.build_request(team_id="T111111")) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) diff --git a/tests/scenario_tests/test_app_bot_only.py b/tests/scenario_tests/test_app_bot_only.py new file mode 100644 index 000000000..9a4c932b9 --- /dev/null +++ b/tests/scenario_tests/test_app_bot_only.py @@ -0,0 +1,260 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest, Say +from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class LegacyMemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + app_mention_request_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + + @staticmethod + def handle_app_mention(body, say: Say, payload, event): + assert body["event"] == payload + assert payload == event + say("What's up?") + + oauth_settings_bot_only = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + oauth_settings = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=False, + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + def build_app_mention_request(self): + timestamp, body = str(int(time())), json.dumps(self.app_mention_request_body) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_installation_store_bot_only_default(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MemoryInstallationStore(), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_bot_only_false(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MemoryInstallationStore(), + # the default is False + installation_store_bot_only=False, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_bot_only(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_bot_only_oauth_settings(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=self.oauth_settings_bot_only, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_bot_only_oauth_settings_conflicts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store_bot_only=True, + oauth_settings=self.oauth_settings, + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_bot_only_oauth_flow(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_flow=OAuthFlow(settings=self.oauth_settings_bot_only), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_bot_only_oauth_flow_conflicts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store_bot_only=True, + oauth_flow=OAuthFlow(settings=self.oauth_settings), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) diff --git a/tests/scenario_tests/test_app_custom_authorize.py b/tests/scenario_tests/test_app_custom_authorize.py new file mode 100644 index 000000000..02009162f --- /dev/null +++ b/tests/scenario_tests/test_app_custom_authorize.py @@ -0,0 +1,253 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltContext, BoltRequest, Say +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.authorization.authorize import Authorize +from slack_bolt.error import BoltError +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class CustomAuthorize(Authorize): + def __init__(self, installation_store: InstallationStore): + self.installation_store = installation_store + + def __call__( + self, + *, + context: BoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + ) -> Optional[AuthorizeResult]: + bot_token: Optional[str] = None + user_token: Optional[str] = None + latest_installation: Optional[Installation] = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + this_user_installation: Optional[Installation] = None + if latest_installation is not None: + bot_token = latest_installation.bot_token # this still can be None + user_token = latest_installation.user_token # this still can be None + if latest_installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + user_token = None + latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None + latest_installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + this_user_installation = self.installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + token: Optional[str] = bot_token or user_token + if token is None: + return None + try: + auth_test_api_response = context.client.auth_test(token=token) + authorize_result = AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test_api_response, + bot_token=bot_token, + user_token=user_token, + ) + return authorize_result + except SlackApiError: + return None + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + app_mention_request_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + + @staticmethod + def handle_app_mention(body, say: Say, payload, event): + assert body["event"] == payload + assert payload == event + say("What's up?") + + def build_app_mention_request(self): + timestamp, body = str(int(time())), json.dumps(self.app_mention_request_body) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_installation_store_only(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + installation_store=MemoryInstallationStore(), + ), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_and_authorize(self): + installation_store = MemoryInstallationStore() + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + installation_store=installation_store, + ), + authorize=CustomAuthorize(installation_store), + ) + + app.event("app_mention")(self.handle_app_mention) + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_installation_store_and_func_authorize(self): + installation_store = MemoryInstallationStore() + + def authorize(): + pass + + with pytest.raises(BoltError): + App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + installation_store=installation_store, + ), + authorize=authorize, + ) diff --git a/tests/scenario_tests/test_app_decorators.py b/tests/scenario_tests/test_app_decorators.py new file mode 100644 index 000000000..645725add --- /dev/null +++ b/tests/scenario_tests/test_app_decorators.py @@ -0,0 +1,113 @@ +from typing import Callable + +from slack_sdk import WebClient + +from slack_bolt import App, Ack, BoltResponse +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class NoopAck(Ack): + def __call__(self) -> BoltResponse: + pass + + +class TestAppDecorators: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def test_decorators(self): + try: + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + app = App(signing_secret=self.signing_secret, client=self.web_client) + ack = NoopAck() + + @app.event("app_home_opened") + def handle_events(body: dict): + assert body is not None + + handle_events({}) + assert isinstance(handle_events, Callable) + + @app.message("hi") + def handle_message_events(body: dict): + assert body is not None + + handle_message_events({}) + assert isinstance(handle_message_events, Callable) + + @app.function("reverse") + def handle_function_events(body: dict): + assert body is not None + + handle_function_events({}) + assert isinstance(handle_function_events, Callable) + + @app.command("/hello") + def handle_commands(ack: Ack, body: dict): + assert body is not None + ack() + + handle_commands(ack, {}) + assert isinstance(handle_commands, Callable) + + @app.shortcut("test-shortcut") + def handle_shortcuts(ack: Ack, body: dict): + assert body is not None + ack() + + handle_shortcuts(ack, {}) + assert isinstance(handle_shortcuts, Callable) + + @app.action("some-action-id") + def handle_actions(ack: Ack, body: dict): + assert body is not None + ack() + + handle_actions(ack, {}) + assert isinstance(handle_actions, Callable) + + @app.view("some-callback-id") + def handle_views(ack: Ack, body: dict): + assert body is not None + ack() + + handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.options("some-id") + def handle_views(ack: Ack, body: dict): + assert body is not None + ack() + + handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.error + def handle_errors(body: dict): + assert body is not None + + handle_errors({}) + assert isinstance(handle_errors, Callable) + + @app.use + def middleware(body, next): + assert body is not None + next() + + def next_func(): + pass + + middleware({}, next_func) + assert isinstance(middleware, Callable) + + finally: + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) diff --git a/tests/scenario_tests/test_app_installation_store.py b/tests/scenario_tests/test_app_installation_store.py new file mode 100644 index 000000000..7434ee3e4 --- /dev/null +++ b/tests/scenario_tests/test_app_installation_store.py @@ -0,0 +1,158 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltContext, BoltRequest, Say +from slack_bolt.oauth.oauth_settings import OAuthSettings +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.utils import remove_os_env_temporarily, restore_os_env + + +class MemoryInstallationStore(InstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_app_mention_request(self): + timestamp, body = str(int(time())), json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_authorize_result(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="secret", + scopes=["commands"], + user_scopes=["search:read"], + installation_store=MemoryInstallationStore(), + ), + ) + + @app.event("app_mention") + def handle_app_mention(context: BoltContext, say: Say): + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid" + assert context.authorize_result.user_scopes == ["search:read"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" + say("What's up?") + + response = app.dispatch(self.build_app_mention_request()) + assert response.status == 200 + assert_auth_test_count(self, 2) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) diff --git a/tests/scenario_tests/test_app_using_methods_in_class.py b/tests/scenario_tests/test_app_using_methods_in_class.py new file mode 100644 index 000000000..3124bec8b --- /dev/null +++ b/tests/scenario_tests/test_app_using_methods_in_class.py @@ -0,0 +1,215 @@ +import inspect +import json +from time import time +from typing import Callable + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import Ack, App, BoltContext, BoltRequest, Say +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.utils import remove_os_env_temporarily, restore_os_env + + +class TestAppUsingMethodsInClass: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_inspect_behaviors(self): + def f(): + pass + + assert inspect.ismethod(f) is False + + class A: + def b(self): + pass + + @classmethod + def c(cls): + pass + + @staticmethod + def d(): + pass + + a = A() + assert inspect.ismethod(a.b) is True + assert inspect.ismethod(A.c) is True + assert inspect.ismethod(A.d) is False + + def run_app_and_verify(self, app: App): + payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", + } + + timestamp, body = str(int(time())), f"payload={json.dumps(payload)}" + request: BoltRequest = BoltRequest( + body=body, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_class_methods(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method) + self.run_app_and_verify(app) + + def test_class_methods_uncommon_name(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method2) + self.run_app_and_verify(app) + + def test_instance_methods(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method) + self.run_app_and_verify(app) + + def test_instance_methods_uncommon_name(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method2) + self.run_app_and_verify(app) + + def test_instance_methods_uncommon_name_3(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method3) + self.run_app_and_verify(app) + + def test_static_methods(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.static_middleware) + app.shortcut("test-shortcut")(AwesomeClass.static_method) + self.run_app_and_verify(app) + + def test_invalid_arg_in_func(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.shortcut("test-shortcut")(top_level_function) + self.run_app_and_verify(app) + + +class AwesomeClass: + def __init__(self, name: str): + self.name = name + + @classmethod + def class_middleware(cls, next: Callable): + next() + + def instance_middleware(self, next: Callable): + next() + + @staticmethod + def static_middleware(next): + next() + + @classmethod + def class_method(cls, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") + + @classmethod + def class_method2(xyz, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") + + def instance_method(self, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>! My name is {self.name}") + + def instance_method2(whatever, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + + text = "hello world" + + def instance_method3(this, ack, logger, say): + ack() + logger.debug(this.text) + say(f"Hi there!") + + @staticmethod + def static_method(context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") + + +def top_level_function(invalid_arg, ack, say): + assert invalid_arg is None + ack() + say("Hi") diff --git a/tests/scenario_tests/test_attachment_actions.py b/tests/scenario_tests/test_attachment_actions.py index 17557fafa..f40deb22b 100644 --- a/tests/scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests/test_attachment_actions.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,7 +20,10 @@ class TestAttachmentActions: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -31,7 +35,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -43,33 +48,52 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None + def test_success_without_type(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("pick_channel_for_fun")(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.attachment_action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -78,43 +102,70 @@ def test_process_before_response(self): process_before_response=True, ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + def test_failure_without_type(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + app.action("unknown")(simple_listener) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) - app.action({"callback_id": "unknown", "type": "interactive_message",})( - simple_listener + def test_failure(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, ) + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + app.action( + { + "callback_id": "unknown", + "type": "interactive_message", + } + )(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.attachment_action("unknown")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) -# https://api.slack.com/legacy/interactive-messages -payload = { +# https://docs.slack.dev/legacy/legacy-messaging/legacy-making-messages-interactive/ +body = { "type": "interactive_message", "actions": [ { @@ -162,9 +213,11 @@ def test_failure_2(self): "trigger_id": "111.222.valid", } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body): +def simple_listener(ack, body, payload, action): + assert body != payload + assert payload == action assert body["trigger_id"] == "111.222.valid" ack() diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py new file mode 100644 index 000000000..13c248693 --- /dev/null +++ b/tests/scenario_tests/test_authorize.py @@ -0,0 +1,259 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest, BoltResponse +from slack_bolt.app import App +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.request.payload_utils import is_event +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" + + +def authorize(enterprise_id, team_id, user_id, client: WebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + auth_test = client.auth_test(token=valid_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, + bot_token=valid_token, + ) + + +def user_authorize(enterprise_id, team_id, user_id, client: WebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + auth_test = client.auth_test(token=valid_user_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, + user_token=valid_user_token, + ) + + +def error_authorize(enterprise_id, team_id, user_id): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + return None + + +class TestAuthorize: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_block_actions_request(self) -> BoltRequest: + timestamp = str(int(time())) + return BoltRequest(body=block_actions_raw_body, headers=self.build_headers(timestamp, block_actions_raw_body)) + + def build_message_changed_event_request(self) -> BoltRequest: + timestamp = str(int(time())) + raw_body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C2147483705", + "ts": "1358878755.000001", + "message": { + "type": "message", + "user": "U2147483697", + "text": "Hello, world!", + "ts": "1355517523.000005", + "edited": {"user": "U2147483697", "ts": "1358878755.000001"}, + }, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + ) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + def test_success(self): + app = App( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_block_actions_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + def test_failure(self): + app = App( + client=self.web_client, + authorize=error_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_block_actions_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 0) + + def test_bot_context_attributes(self): + app = App( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_bot_context_attributes) + + request = self.build_block_actions_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + def test_user_context_attributes(self): + app = App( + client=self.web_client, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_block_actions_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + def test_before_authorize(self): + def skip_message_changed_events(body: dict, payload: dict, next_): + if is_event(body) and payload.get("type") == "message" and payload.get("subtype") == "message_changed": + return BoltResponse(status=200, body="as expected") + next_() + + app = App( + client=self.web_client, + before_authorize=skip_message_changed_events, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_block_actions_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + request = self.build_message_changed_event_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "as expected" + assert_auth_test_count(self, 1) # should be skipped + + +block_actions_body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +block_actions_raw_body = f"payload={quote(json.dumps(block_actions_body))}" + + +def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + ack() + + +def assert_bot_context_attributes(ack, context): + assert context["bot_id"] == "BZYBOTHED" + assert context["bot_user_id"] == "W23456789" + assert context["bot_token"] == "xoxb-valid" + assert context["token"] == "xoxb-valid" + assert context["user_id"] == "W99999" + assert context.get("user_token") is None + ack() + + +def assert_user_context_attributes(ack, context): + assert context.get("bot_id") is None + assert context.get("bot_user_id") is None + assert context.get("bot_token") is None + assert context["token"] == "xoxp-valid" + assert context["user_id"] == "W99999" + assert context["user_token"] == "xoxp-valid" + ack() diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index 42c878e49..281cc606c 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -5,11 +5,12 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltContext from slack_bolt.app import App from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,7 +20,10 @@ class TestBlockActions: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -31,7 +35,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -43,31 +48,35 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_action("a")(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -80,34 +89,76 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + def test_default_type(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.action({"action_id": "a", "block_id": "b"})(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + def test_default_type_no_block_id(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.action({"action_id": "a"})(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + def test_default_type_no_action_id(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.action({"block_id": "b"})(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + def test_default_type_and_unmatched_block_id(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.action({"action_id": "a", "block_id": "bbb"})(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action("aaa")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.block_action("aaa")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) -payload = { +body = { "type": "block_actions", "user": { "id": "W111", @@ -144,9 +195,18 @@ def test_failure_2(self): ], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body): +def simple_listener(ack, body, payload, action, context: BoltContext): assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + assert context.bot_id == "BZYBOTHED" + assert context.bot_user_id == "W23456789" + assert context.bot_token == "xoxb-valid" + assert context.token == "xoxb-valid" + assert context.user_id == "W111" + assert context.user_token is None ack() diff --git a/tests/scenario_tests/test_block_actions_respond.py b/tests/scenario_tests/test_block_actions_respond.py new file mode 100644 index 000000000..2a77261f9 --- /dev/null +++ b/tests/scenario_tests/test_block_actions_respond.py @@ -0,0 +1,174 @@ +from slack_sdk import WebClient + +from slack_bolt import BoltRequest, App, Say, Respond, Ack +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestBlockActionsRespond: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_mock_server_is_running(self): + resp = self.web_client.api_test() + assert resp is not None + + def test_success(self): + app = App(client=self.web_client) + + @app.event("app_mention") + def handle_app_mention_events(say: Say): + say( + text="This is a section block with a button.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + }, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "action_id": "button", + }, + } + ], + ) + + @app.action("button") + def handle_button_clicks(body: dict, ack: Ack, respond: Respond): + respond( + text="hey!", + thread_ts=body["message"]["ts"], + response_type="in_channel", + replace_original=False, + ) + ack() + + # app_mention event + request = BoltRequest( + mode="socket_mode", + body={ + "team_id": "T0G9PQBBK", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@U111> hey", + "user": "U222", + "ts": "1678252212.229129", + "blocks": [ + { + "type": "rich_text", + "block_id": "BCCO", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U111"}, + {"type": "text", "text": " hey"}, + ], + } + ], + } + ], + "team": "T0G9PQBBK", + "channel": "C111", + "event_ts": "1678252212.229129", + }, + "type": "event_callback", + "event_id": "Ev04SPP46R6J", + "event_time": 1678252212, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", + }, + ) + response = app.dispatch(request) + assert response.status == 200 + + # block_actions request + request = BoltRequest( + mode="socket_mode", + body={ + "type": "block_actions", + "user": {"id": "U111"}, + "api_app_id": "A111", + "container": { + "type": "message", + "message_ts": "1678252213.679169", + "channel_id": "C111", + "is_ephemeral": False, + }, + "trigger_id": "4916855695380.xxx.yyy", + "team": {"id": "T0G9PQBBK"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "C111"}, + "message": { + "bot_id": "B111", + "type": "message", + "text": "This is a section block with a button.", + "user": "U222", + "ts": "1678252213.679169", + "app_id": "A111", + "blocks": [ + { + "type": "section", + "block_id": "8KR", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + "verbatim": False, + }, + "accessory": { + "type": "button", + "action_id": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + }, + } + ], + "team": "T0G9PQBBK", + }, + "state": {"values": {}}, + "response_url": "http://localhost:8888/webhook", + "actions": [ + { + "action_id": "button", + "block_id": "8KR", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "type": "button", + "action_ts": "1678252216.469172", + } + ], + }, + ) + response = app.dispatch(request) + assert response.status == 200 diff --git a/tests/scenario_tests/test_block_suggestion.py b/tests/scenario_tests/test_block_suggestion.py index 26409b8eb..67851962a 100644 --- a/tests/scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests/test_block_suggestion.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -20,7 +21,10 @@ class TestBlockSuggestion: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -32,7 +36,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -44,22 +49,21 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) def build_valid_multi_request(self) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_multi_body, headers=self.build_headers(timestamp, raw_multi_body) - ) + return BoltRequest(body=raw_multi_body, headers=self.build_headers(timestamp, raw_multi_body)) def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("es_a")(show_options) request = self.build_valid_request() @@ -67,10 +71,13 @@ def test_success(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_suggestion("es_a")(show_options) request = self.build_valid_request() @@ -78,10 +85,13 @@ def test_success_2(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_multi(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("mes_a")(show_multi_options) request = self.build_valid_multi_request() @@ -89,7 +99,7 @@ def test_success_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -104,7 +114,7 @@ def test_process_before_response(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response_multi(self): app = App( @@ -119,46 +129,83 @@ def test_process_before_response_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.options("mes_a")(show_multi_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.block_suggestion("mes_a")(show_multi_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure_multi(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_multi_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.options("es_a")(show_options) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) + def test_empty_options(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_options) -payload = { + request = self.build_valid_multi_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == """{"options": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_empty_option_groups(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_option_groups) + + request = self.build_valid_multi_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == """{"option_groups": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + +body = { "type": "block_suggestion", "user": { "id": "W111", @@ -231,16 +278,14 @@ def test_failure_multi(self): }, } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -multi_payload = copy.deepcopy(payload) -multi_payload["block_id"] = "mes_b" -multi_payload["action_id"] = "mes_a" -raw_multi_body = f"payload={quote(json.dumps(multi_payload))}" +multi_body = copy.deepcopy(body) +multi_body["block_id"] = "mes_b" +multi_body["action_id"] = "mes_a" +raw_multi_body = f"payload={quote(json.dumps(multi_body))}" -response = { - "options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] -} +response = {"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]} expected_response_body = json.dumps(response) multi_response = { @@ -263,9 +308,25 @@ def test_failure_multi(self): expected_multi_response_body = json.dumps(multi_response) -def show_options(ack): +def show_options(ack, body, payload, options): + assert body == options + assert payload == options ack(response) -def show_multi_options(ack): +def show_multi_options(ack, body, payload, options): + assert body == options + assert payload == options ack(multi_response) + + +def show_empty_options(ack, body, payload, options): + assert body == options + assert payload == options + ack(options=[]) + + +def show_empty_option_groups(ack, body, payload, options): + assert body == options + assert payload == options + ack(option_groups=[]) diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index f2b3e47eb..c3bdbb73d 100644 --- a/tests/scenario_tests/test_dialogs.py +++ b/tests/scenario_tests/test_dialogs.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,7 +20,10 @@ class TestAttachmentActions: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -31,7 +35,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -49,39 +54,66 @@ def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None - def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) - app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})( - handle_suggestion + def test_success_without_type(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, ) - app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})( - handle_submission + app.options("dialog-callback-id")(handle_suggestion) + app.action("dialog-callback-id")(handle_submission_cancellation) + + request = self.build_valid_request(suggestion_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert response.body != "" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + request = self.build_valid_request(submission_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + request = self.build_valid_request(cancellation_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + def test_success(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, ) - app.action( - {"type": "dialog_cancellation", "callback_id": "dialog-callback-id"} - )(handle_cancellation) + app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})(handle_suggestion) + app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})(handle_submission) + app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"})(handle_cancellation) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert_auth_test_count(self, 1) def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.dialog_suggestion("dialog-callback-id")(handle_suggestion) app.dialog_submission("dialog-callback-id")(handle_submission) app.dialog_cancellation("dialog-callback-id")(handle_cancellation) @@ -91,19 +123,19 @@ def test_success_2(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -111,34 +143,28 @@ def test_process_before_response(self): signing_secret=self.signing_secret, process_before_response=True, ) - app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})( - handle_suggestion - ) - app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})( - handle_submission - ) - app.action( - {"type": "dialog_cancellation", "callback_id": "dialog-callback-id"} - )(handle_cancellation) + app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})(handle_suggestion) + app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})(handle_submission) + app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"})(handle_cancellation) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert_auth_test_count(self, 1) def test_process_before_response_2(self): app = App( @@ -155,100 +181,157 @@ def test_process_before_response_2(self): assert response.status == 200 assert response.body == json.dumps(options_response) assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + assert_auth_test_count(self, 1) + + def test_suggestion_failure_without_type(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request(suggestion_raw_body) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + app.options("dialog-callback-iddddd")(handle_suggestion) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) def test_suggestion_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.dialog_suggestion("dialog-callback-iddddd")(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_suggestion_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"})(handle_suggestion) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) - app.options( - {"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"} - )(handle_suggestion) + def test_submission_failure_without_type(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) + + app.action("dialog-callback-iddddd")(handle_submission) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) def test_submission_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.dialog_submission("dialog-callback-iddddd")(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_submission_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request(suggestion_raw_body) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + app.action({"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"})(handle_submission) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + def test_cancellation_failure_without_type(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - app.action( - {"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"} - )(handle_submission) + app.action("dialog-callback-iddddd")(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_cancellation_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.dialog_cancellation("dialog-callback-iddddd")(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_cancellation_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - app.action( - {"type": "dialog_cancellation", "callback_id": "dialog-callback-iddddd"} - )(handle_cancellation) + app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-iddddd"})(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) -suggestion_payload = { +suggestion_body = { "type": "dialog_suggestion", "token": "verification_token", "action_ts": "1596603332.676855", @@ -266,7 +349,7 @@ def test_cancellation_failure_2(self): "state": "Limo", } -submission_payload = { +submission_body = { "type": "dialog_submission", "token": "verification_token", "action_ts": "1596603334.328193", @@ -288,7 +371,7 @@ def test_cancellation_failure_2(self): "state": "Limo", } -cancellation_payload = { +cancellation_body = { "type": "dialog_cancellation", "token": "verification_token", "action_ts": "1596603453.047897", @@ -305,9 +388,9 @@ def test_cancellation_failure_2(self): "state": "Limo", } -suggestion_raw_body = f"payload={quote(json.dumps(suggestion_payload))}" -submission_raw_body = f"payload={quote(json.dumps(submission_payload))}" -cancellation_raw_body = f"payload={quote(json.dumps(cancellation_payload))}" +suggestion_raw_body = f"payload={quote(json.dumps(suggestion_body))}" +submission_raw_body = f"payload={quote(json.dumps(submission_body))}" +cancellation_raw_body = f"payload={quote(json.dumps(cancellation_body))}" def handle_submission(ack): @@ -321,14 +404,27 @@ def handle_submission(ack): "value": "UXD-342", }, {"label": "[FE-459] Remove the marquee tag", "value": "FE-459"}, - {"label": "[FE-238] Too many shades of gray in master CSS", "value": "FE-238",}, + { + "label": "[FE-238] Too many shades of gray in master CSS", + "value": "FE-238", + }, ] } -def handle_suggestion(ack): +def handle_suggestion(ack, body, payload, options): + assert body == options + assert payload == options ack(options_response) -def handle_cancellation(ack): +def handle_cancellation(ack, body, payload, action): + assert body == action + assert payload == action + ack() + + +def handle_submission_cancellation(ack, body, payload, action): + assert body == action + assert payload == action ack() diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py new file mode 100644 index 000000000..856b85ddf --- /dev/null +++ b/tests/scenario_tests/test_error_handler.py @@ -0,0 +1,268 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest, BoltResponse +from slack_bolt.app import App +from slack_bolt.error import BoltUnhandledRequestError +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestErrorHandler: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + # ---------------- + # utilities + # ---------------- + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + body = { + "type": "block_actions", + "user": { + "id": "W111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(body))}" + timestamp = str(int(time())) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + # ---------------- + # tests + # ---------------- + + def test_default(self): + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + + def test_custom(self): + def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + def test_process_before_response_default(self): + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + + def test_process_before_response_custom(self): + def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + def failing_listener(): + raise Exception("Something wrong!") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + def test_unhandled_errors(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_process_before_response(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_no_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + + @app.middleware + def broken_middleware(): + pass + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_process_before_response_no_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + + @app.middleware + def broken_middleware(): + pass + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_global_middleware_errors(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + def broken_middleware(next_): + assert next_ is not None + raise RuntimeError("Something wrong!") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 500 + assert response.body == "" + + @app.error + def handle_errors(body, next_, error): + assert next_ is None + assert body is not None + assert isinstance(error, RuntimeError) + return BoltResponse(status=503, body="as expected") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 503 + assert response.body == "as expected" diff --git a/tests/scenario_tests/test_events.py b/tests/scenario_tests/test_events.py index c926d51c0..0cc6641fa 100644 --- a/tests/scenario_tests/test_events.py +++ b/tests/scenario_tests/test_events.py @@ -1,13 +1,18 @@ import json -from time import time, sleep +import re +from functools import wraps +from time import time +import pytest from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient -from slack_bolt import App, BoltRequest +from slack_bolt import App, BoltContext, BoltRequest, Say from tests.mock_web_api_server import ( - setup_mock_web_api_server, + assert_auth_test_count, + assert_received_request_count, cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -17,7 +22,10 @@ class TestEvents: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -29,7 +37,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -39,7 +48,7 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - valid_event_payload = { + valid_event_body = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -68,19 +77,18 @@ def test_middleware(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @app.event("app_mention") - def handle_app_mention(payload, say): - assert payload == self.valid_event_payload + def handle_app_mention(body, say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event say("What's up?") - timestamp, body = str(int(time())), json.dumps(self.valid_event_payload) - request: BoltRequest = BoltRequest( - body=body, headers=self.build_headers(timestamp, body) - ) + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_middleware_skip(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -90,13 +98,492 @@ def skip_middleware(req, resp, next): pass @app.event("app_mention", middleware=[skip_middleware]) - def handle_app_mention(payload, logger): + def handle_app_mention(body, logger, payload, event): + assert body["event"] == payload + assert payload == event logger.info(payload) - timestamp, body = str(int(time())), json.dumps(self.valid_event_payload) - request: BoltRequest = BoltRequest( - body=body, headers=self.build_headers(timestamp, body) - ) + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + valid_reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + def test_reaction_added(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("reaction_added") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_reaction_added_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_reaction_added_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_stable_auto_ack(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("reaction_added") + def handle_app_mention(): + raise Exception("Something wrong!") + + for _ in range(10): + timestamp, body = ( + str(int(time())), + json.dumps(self.valid_reaction_added_body), + ) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + def test_self_member_join_left_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_member_joined_channel(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_member_left_channel(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_member_join_left_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_app_mention(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + # the listeners should not be executed + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_uninstallation_and_revokes(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("app_uninstalled") + def handler1(say: Say): + say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + def handler2(say: Say): + say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + message_file_share_body = { + "token": "verification-token", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Here is your file!", + "files": [ + { + "id": "F111", + "created": 1610493713, + "timestamp": 1610493713, + "name": "test.png", + "title": "test.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 42706, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": False, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/test.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/test.png", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_360.png", + "thumb_360_w": 358, + "thumb_360_h": 360, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_480.png", + "thumb_480_w": 477, + "thumb_480_h": 480, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_720.png", + "thumb_720_w": 716, + "thumb_720_h": 720, + "original_w": 736, + "original_h": 740, + "thumb_tiny": "xxx", + "permalink": "https://xxx.slack.com/files/U111/F111/test.png", + "permalink_public": "https://slack-files.com/T111-F111-3e534ef8ca", + "has_rich_preview": False, + } + ], + "upload": False, + "blocks": [ + { + "type": "rich_text", + "block_id": "gvM", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Here is your file!"}], + } + ], + } + ], + "user": "U111", + "display_as_bot": False, + "ts": "1610493715.001000", + "channel": "G111", + "subtype": "file_share", + "event_ts": "1610493715.001000", + "channel_type": "group", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + + def test_message_subtypes_0(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event({"type": "message", "subtype": "file_share"}) + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + def test_message_subtypes_1(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event({"type": "message", "subtype": re.compile("file_.+")}) + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + def test_message_subtypes_2(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event({"type": "message", "subtype": ["file_share"]}) + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + def test_message_subtypes_3(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app._client = WebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("message") + def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + # https://github.com/slackapi/bolt-python/issues/199 + def test_invalid_message_events(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + def handle(): + pass + + # valid + app.event("message")(handle) + + with pytest.raises(ValueError): + app.event("message.channels")(handle) + with pytest.raises(ValueError): + app.event("message.groups")(handle) + with pytest.raises(ValueError): + app.event("message.im")(handle) + with pytest.raises(ValueError): + app.event("message.mpim")(handle) + + with pytest.raises(ValueError): + app.event(re.compile("message\\..*"))(handle) + + with pytest.raises(ValueError): + app.event({"type": "message.channels"})(handle) + with pytest.raises(ValueError): + app.event({"type": re.compile("message\\..*")})(handle) + + def test_context_generation(self): + body = { + "token": "verification-token", + "enterprise_id": "E222", # intentionally inconsistent for testing + "team_id": "T222", # intentionally inconsistent for testing + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": "E333", + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + @app.event("member_left_channel") + def handle(context: BoltContext): + assert context.enterprise_id == "E333" + assert context.team_id is None + assert context.is_enterprise_install is True + assert context.user_id == "W111" + + timestamp, json_body = str(int(time())), json.dumps(body) + request: BoltRequest = BoltRequest(body=json_body, headers=self.build_headers(timestamp, json_body)) + response = app.dispatch(request) + assert response.status == 200 + + def test_additional_decorators_1(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @my_decorator + @app.event("app_mention") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_additional_decorators_2(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("app_mention") + @my_decorator + def handle_app_mention(body, say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + +def my_decorator(f): + @wraps(f) + def wrap(*args, **kwargs): + f(*args, **kwargs) + + return wrap diff --git a/tests/scenario_tests/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py new file mode 100644 index 000000000..c95296154 --- /dev/null +++ b/tests/scenario_tests/test_events_assistant.py @@ -0,0 +1,453 @@ +from threading import Event +from typing import Callable + +from slack_sdk.web import WebClient + +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 cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsAssistant: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_thread_started(self): + app = App(client=self.web_client) + assistant = Assistant() + listener_called = Event() + + @assistant.thread_started + def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts, set_status: SetStatus, context: BoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert set_status.thread_ts == context.thread_ts + assert say.thread_ts == context.thread_ts + say("Hi, how can I help you today?") + set_suggested_prompts(prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}]) + set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}], title="foo" + ) + 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_thread_context_changed(self): + app = App(client=self.web_client) + assistant = Assistant() + 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" + 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 listener_called.wait(timeout=0.1) is True + + def test_user_message(self): + app = App(client=self.web_client) + assistant = Assistant() + listener_called = Event() + + @assistant.user_message + def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + try: + set_status("is typing...") + say("Here you are!") + listener_called.set() + except Exception as e: + say(f"Oops, something went wrong (error: {e})") + + app.assistant(assistant) + + request = BoltRequest(body=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_user_message_with_assistant_thread(self): + app = App(client=self.web_client) + assistant = Assistant() + listener_called = Event() + + @assistant.user_message + def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + try: + set_status("is typing...") + say("Here you are!") + listener_called.set() + except Exception as e: + say(f"Oops, something went wrong (error: {e})") + + app.assistant(assistant) + + request = BoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_message_changed(self): + app = App(client=self.web_client) + assistant = Assistant() + listener_called = Event() + + @assistant.user_message + def handle_user_message(): + listener_called.set() + + @assistant.bot_message + def handle_bot_message(): + 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 listener_called.wait(timeout=0.1) is False + + def test_channel_user_message_ignored(self): + app = App(client=self.web_client) + assistant = Assistant() + listener_called = Event() + + @assistant.user_message + def handle_user_message(): + listener_called.set() + + @assistant.bot_message + def handle_bot_message(): + 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 listener_called.wait(timeout=0.1) is False + + def test_channel_message_changed_ignored(self): + app = App(client=self.web_client) + assistant = Assistant() + listener_called = Event() + + @assistant.user_message + def handle_user_message(): + listener_called.set() + + @assistant.bot_message + def handle_bot_message(): + 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 listener_called.wait(timeout=0.1) is False + + def test_assistant_with_custom_listener_middleware(self): + app = App(client=self.web_client) + assistant = Assistant() + listener_called = Event() + middleware_called = Event() + + class TestMiddleware(Middleware): + def process(self, *, req: BoltRequestType, resp: BoltResponse, next: Callable[[], BoltResponse]): + 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 + assert req.context.get("set_suggested_prompts") is not None + assert req.context.get("get_thread_context") is not None + assert req.context.get("save_thread_context") is not None + return next() + + @assistant.thread_started(middleware=[TestMiddleware()]) + def start_thread(): + listener_called.set() + + @assistant.user_message(middleware=[TestMiddleware()]) + def handle_user_message(): + 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 + assert middleware_called.wait(timeout=0.1) is True + + 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 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() + listener_called = Event() + middleware_called = Event() + + class BlockingMiddleware(Middleware): + def process(self, *, req: BoltRequestType, resp: BoltResponse, next: Callable[[], BoltResponse]): + 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): + 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 middleware_called.wait(timeout=0.1) is True + assert listener_called.wait(timeout=0.1) is False + + +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, + } + ], + } + + +thread_started_event_body = build_payload( + { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C222", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + +thread_context_changed_event_body = build_payload( + { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C333", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + + +user_message_event_body = 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", + } +) + + +user_message_event_body_with_assistant_thread = 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": {"XXX": "YYY"}, + } +) + +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", + "subtype": "message_changed", + "message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "assistant_app_thread": {"title": "When Slack was released?", "title_blocks": [], "artifacts": []}, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "D111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "im", + } +) + +channel_user_message_event_body = 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": "C111", + "event_ts": "1726133700.887259", + "channel_type": "channel", + } +) + +channel_message_changed_event_body = build_payload( + { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "C111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "channel", + } +) diff --git a/tests/scenario_tests/test_events_assistant_without_middleware.py b/tests/scenario_tests/test_events_assistant_without_middleware.py new file mode 100644 index 000000000..18072c05e --- /dev/null +++ b/tests/scenario_tests/test_events_assistant_without_middleware.py @@ -0,0 +1,265 @@ +from threading import Event + +from slack_sdk.web import WebClient + +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 tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.scenario_tests.test_events_assistant import ( + channel_message_changed_event_body, + channel_user_message_event_body, + message_changed_event_body, + thread_context_changed_event_body, + thread_started_event_body, + user_message_event_body, + user_message_event_body_with_assistant_thread, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsAssistantWithoutMiddleware: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_thread_started(self): + app = App(client=self.web_client) + listener_called = Event() + + @app.event("assistant_thread_started") + def handle_assistant_thread_started( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + say("Hi, how can I help you today?") + set_suggested_prompts(prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}]) + listener_called.set() + + 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_thread_context_changed(self): + app = App(client=self.web_client) + listener_called = Event() + + @app.event("assistant_thread_context_changed") + def handle_assistant_thread_context_changed( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + listener_called.set() + + request = BoltRequest(body=thread_context_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_user_message(self): + app = App(client=self.web_client) + listener_called = Event() + + @app.message("") + def handle_message( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + set_status("is typing...") + say("Here you are!") + 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 listener_called.wait(timeout=0.1) is True + + def test_user_message_with_assistant_thread(self): + app = App(client=self.web_client) + listener_called = Event() + + @app.message("") + def handle_message( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + set_status("is typing...") + say("Here you are!") + 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 listener_called.wait(timeout=0.1) is True + + def test_message_changed(self): + app = App(client=self.web_client) + listener_called = Event() + + @app.event("message") + def handle_message_event( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == 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 + listener_called.set() + + request = BoltRequest(body=message_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_channel_user_message(self): + app = App(client=self.web_client) + listener_called = Event() + + @app.event("message") + def handle_message_event( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == 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 + listener_called.set() + + request = BoltRequest(body=channel_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_channel_message_changed(self): + app = App(client=self.web_client) + listener_called = Event() + + @app.event("message") + def handle_message_event( + say: Say, + set_status: SetStatus, + set_title: SetTitle, + set_suggested_prompts: SetSuggestedPrompts, + get_thread_context: GetThreadContext, + save_thread_context: SaveThreadContext, + context: BoltContext, + ): + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == 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 + listener_called.set() + + request = BoltRequest(body=channel_message_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert listener_called.wait(timeout=0.1) is True + + def test_assistant_events_conversation_kwargs_disabled(self): + app = App(client=self.web_client, attaching_conversation_kwargs_enabled=False) + + listener_called = Event() + + @app.event("assistant_thread_started") + def start_thread(context: BoltContext): + assert context.get("set_status") is None + assert context.get("set_title") is None + assert context.get("set_suggested_prompts") is None + assert context.get("get_thread_context") is None + assert context.get("save_thread_context") is None + listener_called.set() + + 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 diff --git a/tests/scenario_tests/test_events_ignore_self.py b/tests/scenario_tests/test_events_ignore_self.py new file mode 100644 index 000000000..3c66f3278 --- /dev/null +++ b/tests/scenario_tests/test_events_ignore_self.py @@ -0,0 +1,155 @@ +from time import sleep + +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +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.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsIgnoreSelf: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_self_events(self): + app = App(client=self.web_client) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(0.5) # wait a bit after auto ack() + # The listener should not be executed + assert self.received_requests.get("/chat.postMessage") is None + + def test_self_events_response_url(self): + app = App(client=self.web_client) + + @app.event("message") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=response_url_message_event, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(0.5) # wait a bit after auto ack() + # The listener should not be executed + assert self.received_requests.get("/chat.postMessage") is None + + def test_not_self_events_response_url(self): + app = App(client=self.web_client) + + @app.event("message") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=different_app_response_url_message_event, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_self_events_disabled(self): + app = App( + client=self.web_client, + ignoring_self_events_enabled=False, + ) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + +event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + "channel_type": "channel", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + +response_url_message_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! This is a reply using response_url.", + "ts": "1658282075.825129", + "bot_id": "BZYBOTHED", + "channel": "C111", + "event_ts": "1658282075.825129", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + +different_app_response_url_message_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! This is a reply using response_url.", + "ts": "1658282075.825129", + "bot_id": "B_DIFFERENT_ONE", + "channel": "C111", + "event_ts": "1658282075.825129", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} diff --git a/tests/scenario_tests/test_events_org_apps.py b/tests/scenario_tests/test_events_org_apps.py new file mode 100644 index 000000000..7eed113d5 --- /dev/null +++ b/tests/scenario_tests/test_events_org_apps.py @@ -0,0 +1,250 @@ +import json +from time import sleep, time +from typing import Optional + +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class OrgAppInstallationStore(InstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + assert enterprise_id == "E111" + assert team_id is None + return Installation( + enterprise_id="E111", + team_id=None, + user_id=user_id, + bot_token=valid_token, + bot_id="B111", + ) + + +class Result: + def __init__(self): + self.called = False + + +class TestEventsOrgApps: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client: WebClient = WebClient( + token=None, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_team_access_granted(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_granted", + "team_ids": ["T111", "T222"], + "event_ts": "111.222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + result = Result() + + @app.event("team_access_granted") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert_auth_test_count(self, 1) + sleep(0.5) # wait a bit after auto ack() + assert result.called is True + + def test_team_access_revoked(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_revoked", + "team_ids": ["T111", "T222"], + "event_ts": "1606805732.987656", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805732, + } + + result = Result() + + @app.event("team_access_revoked") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert_auth_test_count(self, 0) + sleep(0.5) # wait a bit after auto ack() + assert result.called is True + + def test_app_home_opened(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "app_home_opened", + "user": "W111", + "channel": "D111", + "tab": "messages", + "event_ts": "1606810927.510671", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810927, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + } + + result = Result() + + @app.event("app_home_opened") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert_auth_test_count(self, 1) + sleep(0.5) # wait a bit after auto ack() + assert result.called is True + + def test_message(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "0186b75a-2ad4-4f36-8ccc-18608b0ac5d1", + "type": "message", + "text": "<@W222>", + "user": "W111", + "ts": "1606810819.000800", + "team": "T111", + "channel": "C111", + "event_ts": "1606810819.000800", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810819, + "authed_users": [], + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", + } + + result = Result() + + @app.event("message") + def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert_auth_test_count(self, 1) + sleep(0.5) # wait a bit after auto ack() + assert result.called is True diff --git a/tests/scenario_tests/test_events_request_verification.py b/tests/scenario_tests/test_events_request_verification.py new file mode 100644 index 000000000..18e65eaec --- /dev/null +++ b/tests/scenario_tests/test_events_request_verification.py @@ -0,0 +1,105 @@ +import json +from time import time + +from slack_sdk.web import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import App, BoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count, + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsRequestVerification: + valid_token = "xoxb-valid" + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_default(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_disabled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + request_verification_enabled=False, + ) + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + # request including invalid headers + expired = int(time()) - 3600 + timestamp, body = str(expired), json.dumps(event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + +event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} diff --git a/tests/scenario_tests/test_events_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_events_shared_channels.py b/tests/scenario_tests/test_events_shared_channels.py new file mode 100644 index 000000000..1a777230d --- /dev/null +++ b/tests/scenario_tests/test_events_shared_channels.py @@ -0,0 +1,488 @@ +import json +from time import sleep, time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Say +from slack_bolt.authorization import AuthorizeResult +from tests.mock_web_api_server import ( + assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +def authorize(enterprise_id, team_id, client: WebClient): + assert enterprise_id == "E_INSTALLED" + assert team_id == "T_INSTALLED" + auth_test = client.auth_test(token=valid_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, + bot_token=valid_token, + ) + + +class TestEventsSharedChannels: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client: WebClient = WebClient( + token=None, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + valid_event_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_INSTALLED", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T_INSTALLED", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + def test_mock_server_is_running(self): + resp = self.web_client.api_test(token=valid_token) + assert resp != None + + def test_middleware(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + @app.event("app_mention") + def handle_app_mention(body, say: Say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_middleware_skip(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + def skip_middleware(req, resp, next): + # return next() + pass + + @app.event("app_mention", middleware=[skip_middleware]) + def handle_app_mention(body, logger, payload, event): + assert body["event"] == payload + assert payload == event + logger.info(payload) + + timestamp, body = str(int(time())), json.dumps(self.valid_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + valid_reaction_added_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + def test_reaction_added(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + @app.event("reaction_added") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_reaction_added_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(self.valid_reaction_added_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_stable_auto_ack(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + @app.event("reaction_added") + def handle_app_mention(): + raise Exception("Something wrong!") + + for _ in range(10): + timestamp, body = ( + str(int(time())), + json.dumps(self.valid_reaction_added_body), + ) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + def test_self_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(0.5) # wait a bit after auto ack() + # The listener should not be executed + assert self.received_requests.get("/chat.postMessage") is None + + def test_self_member_join_left_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + def handle_member_joined_channel(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_member_left_channel(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_member_join_left_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + def handle_app_mention(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_app_mention(say): + say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_uninstallation_and_revokes(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app._client = WebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("app_uninstalled") + def handler1(say: Say): + say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + def handler2(say: Say): + say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_INSTALLED", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_INSTALLED", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + + # this should not be called when we have authorize + assert_auth_test_count(self, 0) + assert_received_request_count(self, path="/chat.postMessage", min_count=2) diff --git a/tests/scenario_tests/test_events_socket_mode.py b/tests/scenario_tests/test_events_socket_mode.py new file mode 100644 index 000000000..81c45b148 --- /dev/null +++ b/tests/scenario_tests/test_events_socket_mode.py @@ -0,0 +1,344 @@ +from time import sleep + +import pytest +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Say +from slack_bolt.error import BoltError +from tests.mock_web_api_server import ( + assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsSocketMode: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + valid_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + + def test_mock_server_is_running(self): + resp = self.web_client.api_test() + assert resp != None + + def test_body_validation(self): + with pytest.raises(BoltError): + BoltRequest(body={"foo": "bar"}, mode="http") + + def test_middleware(self): + app = App(client=self.web_client) + + @app.event("app_mention") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_event_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + request: BoltRequest = BoltRequest(body=self.valid_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_middleware_skip(self): + app = App(client=self.web_client) + + def skip_middleware(req, resp, next): + # return next() + pass + + @app.event("app_mention", middleware=[skip_middleware]) + def handle_app_mention(body, logger, payload, event): + assert body["event"] == payload + assert payload == event + logger.info(payload) + + request: BoltRequest = BoltRequest(body=self.valid_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + valid_reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + def test_reaction_added(self): + app = App(client=self.web_client) + + @app.event("reaction_added") + def handle_app_mention(body, say, payload, event): + assert body == self.valid_reaction_added_body + assert body["event"] == payload + assert payload == event + say("What's up?") + + request: BoltRequest = BoltRequest(body=self.valid_reaction_added_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_stable_auto_ack(self): + app = App(client=self.web_client) + + @app.event("reaction_added") + def handle_app_mention(): + raise Exception("Something wrong!") + + for _ in range(10): + request: BoltRequest = BoltRequest(body=self.valid_reaction_added_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + def test_self_events(self): + app = App(client=self.web_client) + + event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("reaction_added") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + sleep(0.5) # wait a bit after auto ack() + # The listener should not be executed + assert self.received_requests.get("/chat.postMessage") is None + + def test_self_member_join_left_events(self): + app = App(client=self.web_client) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_member_joined_channel(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_member_left_channel(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=join_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + request: BoltRequest = BoltRequest(body=left_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_member_join_left_events(self): + app = App(client=self.web_client) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "U999", # not self + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + def handle_app_mention(say): + say("What's up?") + + @app.event("member_left_channel") + def handle_app_mention(say): + say("What's up?") + + request: BoltRequest = BoltRequest(body=join_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + request: BoltRequest = BoltRequest(body=left_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_uninstallation_and_revokes(self): + app = App(client=self.web_client) + app._client = WebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("app_uninstalled") + def handler1(say: Say): + say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + def handler2(say: Say): + say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: BoltRequest = BoltRequest(body=app_uninstalled_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: BoltRequest = BoltRequest(body=tokens_revoked_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=2) diff --git a/tests/scenario_tests/test_events_token_revocations.py b/tests/scenario_tests/test_events_token_revocations.py new file mode 100644 index 000000000..30e2eff60 --- /dev/null +++ b/tests/scenario_tests/test_events_token_revocations.py @@ -0,0 +1,167 @@ +import json +from time import sleep, time +from typing import Optional + +import pytest as pytest +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +from slack_bolt.error import BoltError +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class MyInstallationStore(InstallationStore): + def __init__(self): + self.delete_bot_called = False + self.delete_installation_called = False + self.delete_all_called = False + + def delete_bot(self, *, enterprise_id: Optional[str], team_id: Optional[str]) -> None: + self.delete_bot_called = True + + def delete_installation( + self, *, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None + ) -> None: + self.delete_installation_called = True + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + assert enterprise_id == "E111" + assert team_id is None + return Installation( + enterprise_id="E111", + team_id=None, + user_id=user_id, + bot_token=valid_token, + bot_id="B111", + ) + + def delete_all(self, *, enterprise_id: Optional[str], team_id: Optional[str]): + super().delete_all(enterprise_id=enterprise_id, team_id=team_id) + self.delete_all_called = True + + +class TestEventsTokenRevocations: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client: WebClient = WebClient( + token=None, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_no_installation_store(self): + self.web_client.token = valid_token + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + with pytest.raises(BoltError): + app.default_tokens_revoked_event_listener() + with pytest.raises(BoltError): + app.default_app_uninstalled_event_listener() + with pytest.raises(BoltError): + app.enable_token_revocation_listeners() + + def test_tokens_revoked(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["W111"], "bot": ["W222"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = app.dispatch(request) + assert response.status == 200 + + # auth.test API call must be skipped + assert_auth_test_count(self, 0) + sleep(0.5) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is False + + def test_app_uninstalled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = app.dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + assert_auth_test_count(self, 0) + sleep(0.5) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is True diff --git a/tests/scenario_tests/test_events_url_verification.py b/tests/scenario_tests/test_events_url_verification.py new file mode 100644 index 000000000..f09aab843 --- /dev/null +++ b/tests/scenario_tests/test_events_url_verification.py @@ -0,0 +1,73 @@ +import json +from time import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsUrlVerification: + valid_token = "xoxb-valid" + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_default(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 200 + assert response.body == """{"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"}""" + assert_auth_test_count(self, 1) + + def test_disabled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + url_verification_enabled=False, + ) + + timestamp, body = str(int(time())), json.dumps(event_body) + request: BoltRequest = BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = app.dispatch(request) + assert response.status == 404 + assert response.body == """{"error": "unhandled request"}""" + assert_auth_test_count(self, 1) + + +event_body = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", +} diff --git a/tests/scenario_tests/test_function.py b/tests/scenario_tests/test_function.py new file mode 100644 index 000000000..1e5e1c423 --- /dev/null +++ b/tests/scenario_tests/test_function.py @@ -0,0 +1,353 @@ +import json +import re +import time +import pytest +from unittest.mock import Mock + +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 ( + assert_received_request_count, + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestFunction: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request_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, 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( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(function_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, "/functions.completeSuccess", 1) + + def test_valid_callback_id_complete(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(complete_it) + + request = self.build_request_from_body(function_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, "/functions.completeSuccess", 1) + + def test_valid_callback_id_error(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse_error) + + request = self.build_request_from_body(function_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, "/functions.completeError", 1) + + def test_invalid_callback_id(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(wrong_id_function_body) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + def test_invalid_declaration(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + func = app.function("reverse") + + with pytest.raises(TypeError): + func("hello world") + + def test_auto_acknowledge_false_with_acknowledging(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False)(just_ack) + + request = self.build_request_from_body(function_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkeypatch): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False)(just_no_ack) + + request = self.build_request_from_body(function_body) + + 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=fake_time, + sleep_mock=Mock(side_effect=fake_sleep), + ) + response = app.dispatch(request) + + assert response.status == 404 + assert_auth_test_count(self, 1) + assert f"WARNING {just_no_ack.__name__} didn't call ack()" in caplog.text + + def test_function_handler_timeout(self, monkeypatch): + timeout = 5 + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False, ack_timeout=timeout)(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=fake_time, + sleep_mock=Mock(side_effect=fake_sleep), + ) + + response = app.dispatch(request) + + assert response.status == 404 + assert_auth_test_count(self, 1) + 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( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(just_no_ack) + assert "WARNING" not in caplog.text + + timeout_argument_name = "ack_timeout" + kwargs = {timeout_argument_name: 5} + + callback_id = "reverse1" + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f'WARNING On @app.function("{callback_id}"), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused' + in caplog.text + ) + + callback_id = re.compile(r"hello \w+") + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f"WARNING On @app.function({callback_id}), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused" + in caplog.text + ) + + +function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + +wrong_id_function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "wrong_callback_id", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + + +def reverse(body, event, context, client, complete, inputs): + assert body == function_body + assert event == function_body["event"] + assert inputs == function_body["event"]["inputs"] + assert context.function_execution_id == "Fx111" + assert complete.function_execution_id == "Fx111" + assert context.function_bot_access_token == "xwfp-valid" + assert context.client.token == "xwfp-valid" + assert client.token == "xwfp-valid" + assert complete.client.token == "xwfp-valid" + assert complete.has_been_called() is False + complete( + outputs={"reverseString": "olleh"}, + ) + assert complete.has_been_called() is True + + +def reverse_error(body, event, fail): + assert body == function_body + assert event == function_body["event"] + assert fail.function_execution_id == "Fx111" + assert fail.has_been_called() is False + fail(error="there was an error") + assert fail.has_been_called() is True + + +def complete_it(body, event, complete): + assert body == function_body + assert event == function_body["event"] + complete(outputs={}) + + +def just_ack(ack, body, event): + assert body == function_body + assert event == function_body["event"] + ack() + + +def just_no_ack(body, event): + assert body == function_body + assert event == function_body["event"] diff --git a/tests/scenario_tests/test_installation_store_authorize.py b/tests/scenario_tests/test_installation_store_authorize.py new file mode 100644 index 000000000..4cfd0589d --- /dev/null +++ b/tests/scenario_tests/test_installation_store_authorize.py @@ -0,0 +1,148 @@ +import json +from time import time +from typing import Optional +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" + + +class MyInstallationStore(InstallationStore): + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T111", + bot_token=valid_token, + bot_id="B111", + bot_user_id="W111", + bot_scopes=["commands"], + installed_at=time(), + ) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return None + + +class TestInstallationStoreAuthorize: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + timestamp = str(int(time())) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + def test_success(self): + app = App( + client=self.web_client, + installation_store=MyInstallationStore(), + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + +body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + ack() diff --git a/tests/scenario_tests/test_lazy.py b/tests/scenario_tests/test_lazy.py new file mode 100644 index 000000000..3b2aefbea --- /dev/null +++ b/tests/scenario_tests/test_lazy.py @@ -0,0 +1,202 @@ +import json +import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +from tests.mock_web_api_server import assert_received_request_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestErrorHandler: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + # ---------------- + # utilities + # ---------------- + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + body = { + "type": "block_actions", + "user": { + "id": "W111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(body))}" + timestamp = str(int(time.time())) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + # ---------------- + # tests + # ---------------- + + def test_lazy(self): + def just_ack(ack): + ack() + + def async1(say): + time.sleep(0.3) + say(text="lazy function 1") + + def async2(say): + time.sleep(0.5) + say(text="lazy function 2") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")( + ack=just_ack, + lazy=[async1, async2], + ) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_lazy_class(self): + def just_ack(ack): + ack() + + class LazyClass: + def __call__(self, say): + time.sleep(0.3) + say(text="lazy function 1") + + def async2(say): + time.sleep(0.5) + say(text="lazy function 2") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")( + ack=just_ack, + lazy=[LazyClass(), async2], + ) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) + + def test_issue_545_context_copy_failure(self): + def just_ack(ack): + ack() + + class LazyClass: + def __call__(self, context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + time.sleep(0.3) + say(text="lazy function 1") + + def async2(context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + time.sleep(0.5) + say(text="lazy function 2") + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + def set_ssl_context(context, next_): + import ssl + + context["foo"] = "FOO" + # This causes an error when starting lazy listener executions + 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) + # Traceback (most recent call last): + # File "/path/to/bolt-python/slack_bolt/app/app.py", line 545, in dispatch + # ] = self._listener_runner.run( + # File "/path/to/bolt-python/slack_bolt/listener/thread_runner.py", line 166, in run + # self._start_lazy_function(lazy_func, request) + # File "/path/to/bolt-python/slack_bolt/listener/thread_runner.py", line 193, in _start_lazy_function + # copied_request = self._build_lazy_request(request, func_name) + # File "/path/to/bolt-python/slack_bolt/listener/thread_runner.py", line 198, in _build_lazy_request + # copied_request = create_copy(request) + # File "/path/to/bolt-python/slack_bolt/util/utils.py", line 48, in create_copy + # return copy.deepcopy(original) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 270, in _reconstruct + # state = deepcopy(state, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 146, in deepcopy + # y = copier(x, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 230, in _deepcopy_dict + # y[deepcopy(key, memo)] = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 296, in _reconstruct + # value = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 161, in deepcopy + # rv = reductor(4) + # TypeError: cannot pickle 'SSLContext' object + + app.action("a")( + ack=just_ack, + lazy=[LazyClass(), async2], + ) + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert_received_request_count(self, path="/chat.postMessage", min_count=2) diff --git a/tests/scenario_tests/test_listener_middleware.py b/tests/scenario_tests/test_listener_middleware.py new file mode 100644 index 000000000..11ca31115 --- /dev/null +++ b/tests/scenario_tests/test_listener_middleware.py @@ -0,0 +1,119 @@ +import json +from time import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import BoltResponse +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestListenerMiddleware: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + body = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + def build_request(self) -> BoltRequest: + timestamp, body = str(int(time())), json.dumps(self.body) + return BoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + def test_return_response(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut( + constraints="test-shortcut", + middleware=[listener_middleware_returning_response], + ) + def handle(ack): + ack() + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "listener middleware" + + def test_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut(constraints="test-shortcut", middleware=[just_next]) + def handle(ack): + ack() + + response = app.dispatch(self.build_request()) + assert response.status == 200 + + def test_class_next(self): + class NextClass: + def __call__(self, next): + next() + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut(constraints="test-shortcut", middleware=[NextClass()]) + def handle(ack): + ack() + + response = app.dispatch(self.build_request()) + assert response.status == 200 + + +def listener_middleware_returning_response(): + return BoltResponse(status=200, body="listener middleware") + + +def just_next(next): + next() diff --git a/tests/scenario_tests/test_message.py b/tests/scenario_tests/test_message.py index 4f315c165..892776620 100644 --- a/tests/scenario_tests/test_message.py +++ b/tests/scenario_tests/test_message.py @@ -5,11 +5,14 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient +from slack_bolt import BoltResponse from slack_bolt.app import App from slack_bolt.request import BoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, + assert_auth_test_count, + assert_received_request_count, cleanup_mock_web_api_server, + setup_mock_web_api_server, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,7 +22,10 @@ class TestMessage: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -31,7 +37,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -41,52 +48,209 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_request(self) -> BoltRequest: - timestamp, body = str(int(time.time())), json.dumps(message_payload) + def build_request_from_body(self, message_body: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(message_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def build_request(self) -> BoltRequest: + return self.build_request_from_body(message_body) + + def build_request2(self) -> BoltRequest: + return self.build_request_from_body(message_body2) + + def build_request3(self) -> BoltRequest: + return self.build_request_from_body(message_body3) + def test_string_keyword(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("Hello")(whats_up) request = self.build_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_all_message_matching_1(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message("") + def handle_all_new_messages(say): + say("Thanks!") + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_all_message_matching_2(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message() + def handle_all_new_messages(say): + say("Thanks!") + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_string_keyword_capturing(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_string_keyword_capturing2(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))(verify_matches) + + request = self.build_request2() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) + + def test_string_keyword_capturing_multi_capture(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message(re.compile("([a-z|A-Z]{3,}-[0-9]+)"))(verify_matches_multi) + + request = self.build_request3() + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_string_keyword_unmatched(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("HELLO")(whats_up) request = self.build_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_regexp_keyword(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("He.lo"))(whats_up) request = self.build_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/chat.postMessage", min_count=1) def test_regexp_keyword_unmatched(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message(re.compile("HELLO"))(whats_up) request = self.build_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + # https://github.com/slackapi/bolt-python/issues/232 + def test_issue_232_message_listener_middleware(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + called = { + "first": False, + "second": False, + } + + def this_should_be_skipped(): + return BoltResponse(status=500, body="failed") + + @app.message("first", middleware=[this_should_be_skipped]) + def first(): + called["first"] = True + + @app.message("second", middleware=[]) + def second(): + called["second"] = True + + request = self.build_request_from_body( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "This message should match the second listener only", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + } + ) + response = app.dispatch(request) + assert response.status == 200 + assert called["first"] == False + assert called["second"] == True + + def test_issue_561_matchers(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def just_fail(): + raise "This matcher should not be called!" + + @app.message("xxx", matchers=[just_fail]) + def just_ack(): + raise "This listener should not be called!" + + request = self.build_request() + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) -message_payload = { +message_body = { "token": "verification_token", "team_id": "T111", "enterprise_id": "E111", @@ -121,6 +285,69 @@ def test_regexp_keyword_unmatched(self): } -def whats_up(payload, say): - assert payload == message_payload +def whats_up(body, payload, message, say): + assert body == message_body + assert payload == message say("What's up?") + + +message_body2 = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "We've received 103 messages from you!", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + "authed_users": ["W111"], +} + + +message_body3 = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "Please fix JIRA-1234, SCM-567 and BUG-169 as soon as you can!", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + "authed_users": ["W111"], +} + + +def verify_matches(context, say, body, payload, message): + assert context["matches"] == ("103", "you") + assert context.matches == ("103", "you") + assert body["event"] == message + assert payload == message + say("Thanks!") + + +def verify_matches_multi(context, say, body, payload, message): + assert context["matches"] == ("JIRA-1234", "SCM-567", "BUG-169") + assert context.matches == ("JIRA-1234", "SCM-567", "BUG-169") + assert body["event"] == message + assert payload == message + say("Thanks!") diff --git a/tests/scenario_tests/test_message_bot.py b/tests/scenario_tests/test_message_bot.py new file mode 100644 index 000000000..bc756be7d --- /dev/null +++ b/tests/scenario_tests/test_message_bot.py @@ -0,0 +1,203 @@ +import json +import re +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestBotMessage: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(event_payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_message_handler(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + result = {"call_count": 0} + + @app.message("Hi there!") + def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(user_message_event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(bot_message_event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(classic_bot_message_event_payload) + response = app.dispatch(request) + assert response.status == 200 + + assert_auth_test_count(self, 1) + time.sleep(0.5) # wait a bit after auto ack() + assert result["call_count"] == 3 + + +user_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "968c94da-c271-4f2a-8ec9-12a9985e5df4", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "W111", + "ts": "1610261659.001400", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "bN8", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hi there! Thanks for sharing the info!", + } + ], + } + ], + } + ], + "channel": "C111", + "event_ts": "1610261659.001400", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "bot_id": "B999", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "UB111", + "ts": "1610261539.000900", + "team": "T111", + "bot_profile": { + "id": "B999", + "deleted": False, + "name": "other-app", + "updated": 1607307935, + "app_id": "A222", + "icons": { + "image_36": "https://a.slack-edge.com/80588/img/plugins/app/bot_36.png", + "image_48": "https://a.slack-edge.com/80588/img/plugins/app/bot_48.png", + "image_72": "https://a.slack-edge.com/80588/img/plugins/app/service_72.png", + }, + "team_id": "T111", + }, + "channel": "C111", + "event_ts": "1610261539.000900", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev222", + "event_time": 1610261539, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +classic_bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! Thanks for sharing the info!", + "ts": "1610262363.001600", + "username": "classic-bot", + "bot_id": "B888", + "channel": "C111", + "event_ts": "1610262363.001600", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev333", + "event_time": 1610262363, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests/test_message_changed.py b/tests/scenario_tests/test_message_changed.py new file mode 100644 index 000000000..e04aa3703 --- /dev/null +++ b/tests/scenario_tests/test_message_changed.py @@ -0,0 +1,124 @@ +import json +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestMessageChanged: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(event_payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_user_and_channel_id_in_context(self): + app = App(client=self.web_client, signing_secret=self.signing_secret, process_before_response=True) + + @app.event({"type": "message", "subtype": "message_changed"}) + def handle_message_changed(context): + # These should come from the main event payload part + assert context.channel_id == "C111" + assert context.user_id == "U111" + # The following ones come from authorizations[0] + assert context.team_id == "T-auth" + assert context.enterprise_id == "E-auth" + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_changed", + "message": { + "type": "message", + "text": "updated message", + "user": "U111", + "team": "T111", + "edited": {"user": "U111", "ts": "1665102362.000000"}, + "blocks": [ + { + "type": "rich_text", + "block_id": "xwvU3", + "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "updated message"}]}], + } + ], + }, + "previous_message": { + "type": "message", + "text": "original message", + "user": "U222", + "team": "T111", + "ts": "1665102338.901939", + "blocks": [ + { + "type": "rich_text", + "block_id": "URf", + "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "original message"}]}], + } + ], + }, + "channel": "C111", + "hidden": True, + "ts": "1665102362.013600", + "event_ts": "1665102362.013600", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1665102362, + "authorizations": [ + { + "enterprise_id": "E-auth", + "team_id": "T-auth", + "user_id": "U-auth", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T-auth-C111", +} diff --git a/tests/scenario_tests/test_message_deleted.py b/tests/scenario_tests/test_message_deleted.py new file mode 100644 index 000000000..5ca5ef13b --- /dev/null +++ b/tests/scenario_tests/test_message_deleted.py @@ -0,0 +1,104 @@ +import json +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestMessageDeleted: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(event_payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_user_and_channel_id_in_context(self): + app = App(client=self.web_client, signing_secret=self.signing_secret, process_before_response=True) + + @app.event({"type": "message", "subtype": "message_deleted"}) + def handle_message_deleted(context): + # These should come from the main event payload part + assert context.channel_id == "C111" + assert context.user_id == "U111" + # The following ones come from authorizations[0] + assert context.team_id == "T-auth" + assert context.enterprise_id == "E-auth" + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_deleted", + "previous_message": { + "type": "message", + "text": "Delete this message", + "user": "U111", + "team": "T111", + "ts": "1665368619.804829", + }, + "channel": "C111", + "hidden": True, + "deleted_ts": "1665368619.804829", + "event_ts": "1665368629.007100", + "ts": "1665368629.007100", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1665368629, + "authorizations": [ + { + "enterprise_id": "E-auth", + "team_id": "T-auth", + "user_id": "U-auth", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests/test_message_file_share.py b/tests/scenario_tests/test_message_file_share.py new file mode 100644 index 000000000..d014931e3 --- /dev/null +++ b/tests/scenario_tests/test_message_file_share.py @@ -0,0 +1,169 @@ +import json +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestMessageFileShare: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_message_handler(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + result = {"call_count": 0} + + @app.message("Hi there!") + def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + assert_auth_test_count(self, 1) + time.sleep(0.5) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "xxx", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Hi there!", + "files": [ + { + "id": "F111", + "created": 1652227642, + "timestamp": 1652227642, + "name": "file.png", + "title": "file.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 92582, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": True, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/file.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/file.png", + "media_display_type": "unknown", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_360.png", + "thumb_360_w": 360, + "thumb_360_h": 115, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_480.png", + "thumb_480_w": 480, + "thumb_480_h": 153, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_720.png", + "thumb_720_w": 720, + "thumb_720_h": 230, + "thumb_800": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_800.png", + "thumb_800_w": 800, + "thumb_800_h": 255, + "thumb_960": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_960.png", + "thumb_960_w": 960, + "thumb_960_h": 306, + "thumb_1024": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_1024.png", + "thumb_1024_w": 1024, + "thumb_1024_h": 327, + "original_w": 1134, + "original_h": 362, + "thumb_tiny": "AwAPADCkCAOcUEj0zTaKAHZHpT9oxwR+VRVMBQA0r3yPypu0f3v0p5yBTCcmmI//2Q==", + "permalink": "https://xxx.slack.com/files/U111/F111/file.png", + "permalink_public": "https://slack-files.com/T111-F111-faecabecf7", + "has_rich_preview": False, + } + ], + "upload": False, + "user": "U111", + "display_as_bot": False, + "ts": "1652227646.593159", + "blocks": [ + { + "type": "rich_text", + "block_id": "ba4", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Hi there!"}], + } + ], + } + ], + "client_msg_id": "ca088267-717f-41a8-9db8-c98ae14ad6a0", + "channel": "C111", + "subtype": "file_share", + "event_ts": "1652227646.593159", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev03EGJQAVMM", + "event_time": 1652227646, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", +} diff --git a/tests/scenario_tests/test_message_thread_broadcast.py b/tests/scenario_tests/test_message_thread_broadcast.py new file mode 100644 index 000000000..8d2ed7aa2 --- /dev/null +++ b/tests/scenario_tests/test_message_thread_broadcast.py @@ -0,0 +1,119 @@ +import json +import time + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestMessageThreadBroadcast: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(event_payload) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_message_handler(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + result = {"call_count": 0} + + @app.message("Hi there!") + def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = app.dispatch(request) + assert response.status == 200 + + assert_auth_test_count(self, 1) + time.sleep(0.5) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "thread_broadcast", + "text": "Hi there!", + "user": "U111", + "ts": "1633670813.007500", + "thread_ts": "1633663824.000500", + "root": { + "client_msg_id": "111-222-333-444-555", + "type": "message", + "text": "Write in the thread :bow:", + "user": "U111", + "ts": "1633663824.000500", + "team": "T111", + "thread_ts": "1633663824.000500", + "reply_count": 17, + "reply_users_count": 1, + "latest_reply": "1633670813.007500", + "reply_users": ["U111"], + "is_locked": False, + }, + "client_msg_id": "111-222-333-444-666", + "channel": "C111", + "event_ts": "1633670813.007500", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests/test_middleware.py b/tests/scenario_tests/test_middleware.py new file mode 100644 index 000000000..6553445df --- /dev/null +++ b/tests/scenario_tests/test_middleware.py @@ -0,0 +1,253 @@ +import json +import logging +from time import time, sleep +from typing import Callable, Optional + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import BoltResponse, CustomListenerMatcher +from slack_bolt.app import App +from slack_bolt.listener import CustomListener +from slack_bolt.listener.thread_runner import ThreadListenerRunner +from slack_bolt.middleware import Middleware +from slack_bolt.request import BoltRequest +from slack_bolt.request.payload_utils import is_shortcut +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, + assert_received_request_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestMiddleware: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def build_request(self) -> BoltRequest: + body = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + timestamp, body = str(int(time())), json.dumps(body) + return BoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + def test_no_next_call(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(no_next) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 404 + assert_auth_test_count(self, 1) + + def test_next_call(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(just_next) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert_auth_test_count(self, 1) + + def test_decorator_next_call(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + def just_next(next): + next() + + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert_auth_test_count(self, 1) + + def test_next_call_(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(just_next_) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert_auth_test_count(self, 1) + + def test_decorator_next_call_(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + def just_next_(next_): + next_() + + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert_auth_test_count(self, 1) + + def test_class_call(self): + class NextClass: + def __call__(self, next): + next() + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(NextClass()) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert_auth_test_count(self, 1) + + def test_class_call_(self): + class NextUnderscoreClass: + def __call__(self, next_): + next_() + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(NextUnderscoreClass()) + app.shortcut("test-shortcut")(just_ack) + + response = app.dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + assert_auth_test_count(self, 1) + + def test_lazy_listener_middleware(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + unmatch_middleware = LazyListenerStarter("xxxx") + app.use(unmatch_middleware) + + response = app.dispatch(self.build_request()) + assert response.status == 404 + assert_auth_test_count(self, 1) + + my_middleware = LazyListenerStarter("test-shortcut") + app.use(my_middleware) + response = app.dispatch(self.build_request()) + assert response.status == 200 + count = 0 + while count < 20 and my_middleware.lazy_called is False: + sleep(0.05) + assert my_middleware.lazy_called is True + + +def just_ack(ack): + ack("acknowledged!") + + +def no_next(): + pass + + +def just_next(next): + next() + + +def just_next_(next_): + next_() + + +class LazyListenerStarter(Middleware): + lazy_called: bool + callback_id: str + + def __init__(self, callback_id: str): + self.lazy_called = False + self.callback_id = callback_id + + def lazy_listener(self): + self.lazy_called = True + + def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]: + if is_shortcut(req.body): + listener = CustomListener( + app_name="test-app", + ack_function=just_ack, + lazy_functions=[self.lazy_listener], + matchers=[ + CustomListenerMatcher( + app_name="test-app", + func=lambda payload: payload.get("callback_id") == self.callback_id, + ) + ], + middleware=[], + base_logger=req.context.logger, + ) + if listener.matches(req=req, resp=resp): + listener_runner: ThreadListenerRunner = req.context.listener_runner + response = listener_runner.run( + request=req, + response=resp, + listener_name="test", + listener=listener, + ) + if response is not None: + return response + next() diff --git a/tests/scenario_tests/test_shortcut.py b/tests/scenario_tests/test_shortcut.py index c687937bd..65fc0f13e 100644 --- a/tests/scenario_tests/test_shortcut.py +++ b/tests/scenario_tests/test_shortcut.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,7 +20,10 @@ class TestShortcut: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -31,7 +35,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -49,63 +54,86 @@ def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None - def test_success_global(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + # NOTE: This is a compatible behavior with Bolt for JS + def test_success_both_global_and_message(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert response.status == 200 + assert_auth_test_count(self, 1) + + def test_success_global(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.shortcut("test-shortcut")(simple_listener) + + request = self.build_valid_request(global_shortcut_raw_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) def test_success_global_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.global_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_success_message(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) - app.shortcut({"type": "message_action", "callback_id": "test-shortcut"})( - simple_listener + app = App( + client=self.web_client, + signing_secret=self.signing_secret, ) + app.shortcut({"type": "message_action", "callback_id": "test-shortcut"})(simple_listener) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_success_message_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_process_before_response_global(self): app = App( @@ -118,39 +146,45 @@ def test_process_before_response_global(self): request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.shortcut("another-one")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.global_shortcut("another-one")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 3 + assert_auth_test_count(self, 1) -global_shortcut_payload = { +global_shortcut_body = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -165,7 +199,7 @@ def test_failure_2(self): "trigger_id": "111.111.xxxxxx", } -message_shortcut_payload = { +message_shortcut_body = { "type": "message_action", "token": "verification_token", "action_ts": "1583637157.207593", @@ -206,9 +240,11 @@ def test_failure_2(self): "response_url": "https://hooks.slack.com/app/T111/111/xxx", } -global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_payload))}" -message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_payload))}" +global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_body))}" +message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_body))}" -def simple_listener(ack): +def simple_listener(ack, body, payload, shortcut): + assert body == shortcut + assert payload == shortcut ack() diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index 4e732a348..ae7e7c53b 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -9,6 +9,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -18,7 +19,10 @@ class TestSlashCommand: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -30,7 +34,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -41,7 +46,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> BoltRequest: - timestamp, body = str(int(time())), json.dumps(slash_command_payload) + timestamp, body = str(int(time())), json.dumps(slash_command_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) def test_mock_server_is_running(self): @@ -49,13 +54,16 @@ def test_mock_server_is_running(self): assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.command("/hello-world")(commander) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -68,22 +76,53 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.command("/another-one")(commander) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + 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_payload = ( +slash_command_body = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -100,5 +139,7 @@ def test_failure(self): ) -def commander(ack): +def commander(ack, body, payload, command): + assert body == command + assert payload == command ack() diff --git a/tests/scenario_tests/test_ssl_check.py b/tests/scenario_tests/test_ssl_check.py index bb27939fd..937f9d7e4 100644 --- a/tests/scenario_tests/test_ssl_check.py +++ b/tests/scenario_tests/test_ssl_check.py @@ -16,7 +16,10 @@ class TestSSLCheck: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -28,7 +31,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def test_mock_server_is_running(self): @@ -51,3 +55,24 @@ def test_ssl_check(self): response = app.dispatch(request) assert response.status == 200 assert response.body == "" + + def test_ssl_check_disabled(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ssl_check_enabled=False, + ) + + timestamp, body = str(int(time())), "token=random&ssl_check=1" + request: BoltRequest = BoltRequest( + body=body, + query={}, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = app.dispatch(request) + assert response.status == 404 + assert response.body == """{"error": "unhandled request"}""" diff --git a/tests/scenario_tests/test_view_closed.py b/tests/scenario_tests/test_view_closed.py index b2ab0d5ab..e48e6e33a 100644 --- a/tests/scenario_tests/test_view_closed.py +++ b/tests/scenario_tests/test_view_closed.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,7 +20,10 @@ class TestViewClosed: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -31,7 +35,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -43,31 +48,35 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view({"type": "view_closed", "callback_id": "view-id"})(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view_closed("view-id")(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -80,46 +89,55 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view({"type": "view_closed", "callback_id": "view-idddd"})(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view_closed("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) -payload = { +body = { "type": "view_closed", "team": { "id": "T111", @@ -143,7 +161,10 @@ def test_failure_2(self): { "type": "input", "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, "optional": False, "element": {"type": "plain_text_input", "action_id": "maBWU"}, } @@ -152,11 +173,20 @@ def test_failure_2(self): "callback_id": "view-id", "state": {"values": {}}, "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, + "title": { + "type": "plain_text", + "text": "My App", + }, "clear_on_close": False, "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "previous_view_id": None, "root_view_id": "V111", "app_id": "A111", @@ -167,9 +197,11 @@ def test_failure_2(self): "response_urls": [], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -def simple_listener(ack, body): - assert body["view"]["private_metadata"] == "This is for you!" +def simple_listener(ack, body, payload, view): + assert body["view"] == payload + assert payload == view + assert view["private_metadata"] == "This is for you!" ack() diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index 825c06781..0f5b23f85 100644 --- a/tests/scenario_tests/test_view_submission.py +++ b/tests/scenario_tests/test_view_submission.py @@ -5,21 +5,201 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltContext from slack_bolt.app import App from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env +body = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": {"values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}}}, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +def simple_listener(ack, body, payload, view): + assert body["trigger_id"] == "111.222.valid" + assert body["view"] == payload + assert payload == view + assert view["private_metadata"] == "This is for you!" + ack() + + +response_url_payload_body = { + "type": "view_submission", + "team": {"id": "T111", "domain": "test-test-test"}, + "user": { + "id": "U111", + "username": "test-test-test", + "name": "test-test-test", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [], + "callback_id": "view-id", + "state": {}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [ + { + "block_id": "b", + "action_id": "a", + "channel_id": "C111", + "response_url": "http://localhost:8888/webhook", + } + ], + "is_enterprise_install": False, +} + + +raw_response_url_body = f"payload={quote(json.dumps(response_url_payload_body))}" + + +connect_channel_payload = { + "type": "view_submission", + "team": { + "id": "T-other-side", + "domain": "other-side", + "enterprise_id": "E-other-side", + "enterprise_name": "Kaz Sandbox Org", + }, + "user": {"id": "W111", "username": "kaz", "name": "kaz", "team_id": "T-other-side"}, + "api_app_id": "A1111", + "token": "legacy-fixed-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V11111", + "team_id": "T-other-side", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "zniAM", + "label": {"type": "plain_text", "text": "Label"}, + "element": { + "type": "plain_text_input", + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "qEJr", + }, + } + ], + "private_metadata": "", + "callback_id": "view-id", + "state": {"values": {"zniAM": {"qEJr": {"type": "plain_text_input", "value": "Hi there!"}}}}, + "hash": "1664950703.CmTS8F7U", + "title": {"type": "plain_text", "text": "My App"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "root_view_id": "V00000", + "app_id": "A1111", + "external_id": "", + "app_installed_team_id": "T-installed-workspace", + "bot_id": "B1111", + }, + "enterprise": {"id": "E-other-side", "name": "Kaz Sandbox Org"}, +} + +connect_channel_body = f"payload={quote(json.dumps(connect_channel_payload))}" + + +def verify_connected_channel(ack, context: BoltContext): + assert context.team_id == "T-installed-workspace" + ack() + class TestViewSubmission: signing_secret = "secret" valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -31,7 +211,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -41,33 +222,37 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> BoltRequest: + def build_valid_request(self, body: str = raw_body) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) def test_mock_server_is_running(self): resp = self.web_client.api_test() assert resp != None def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view("view-id")(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view_submission("view-id")(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -80,88 +265,62 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) def test_failure_2(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.view_submission("view-idddd")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert_auth_test_count(self, 1) + def test_response_urls(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) -payload = { - "type": "view_submission", - "team": { - "id": "T111", - "domain": "workspace-domain", - "enterprise_id": "E111", - "enterprise_name": "Sandbox Org", - }, - "user": { - "id": "W111", - "username": "primary-owner", - "name": "primary-owner", - "team_id": "T111", - }, - "api_app_id": "A111", - "token": "verification_token", - "trigger_id": "111.222.valid", - "view": { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "input", - "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, - "optional": False, - "element": {"type": "plain_text_input", "action_id": "maBWU"}, - } - ], - "private_metadata": "This is for you!", - "callback_id": "view-id", - "state": { - "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} - }, - "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, - "clear_on_close": False, - "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "previous_view_id": None, - "root_view_id": "V111", - "app_id": "A111", - "external_id": "", - "app_installed_team_id": "T111", - "bot_id": "B111", - }, - "response_urls": [], -} + @app.view("view-id") + def check(ack, respond): + respond("Hi") + ack() -raw_body = f"payload={quote(json.dumps(payload))}" + request = self.build_valid_request(raw_response_url_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + def test_connected_channels(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.view("view-id")(verify_connected_channel) -def simple_listener(ack, body): - assert body["trigger_id"] == "111.222.valid" - assert body["view"]["private_metadata"] == "This is for you!" - ack() + request = self.build_valid_request(body=connect_channel_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) diff --git a/tests/scenario_tests/test_web_client_customization.py b/tests/scenario_tests/test_web_client_customization.py new file mode 100644 index 000000000..f78c7747a --- /dev/null +++ b/tests/scenario_tests/test_web_client_customization.py @@ -0,0 +1,183 @@ +import json +import logging +from time import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.http_retry import all_builtin_retry_handlers +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest +from slack_bolt.app import App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWebClientCustomization: + valid_token = "xoxb-valid" + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + test_logger = logging.getLogger("test.logger") + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> BoltRequest: + timestamp = str(int(time())) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + def test_web_client_customization(self): + self.web_client.retry_handlers = all_builtin_retry_handlers() + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.action("a") + def listener(ack, client): + assert len(client.retry_handlers) == 2 + ack() + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + def test_web_client_logger_is_default_app_logger(self): + app = App(token=self.valid_token, signing_secret=self.signing_secret, token_verification_enabled=False) + assert app.client.logger == app.logger + + def test_web_client_logger_is_app_logger(self): + app = App( + token=self.valid_token, + signing_secret=self.signing_secret, + logger=self.test_logger, + token_verification_enabled=False, + ) + assert app.client.logger == app.logger + assert app.client.logger == self.test_logger + + def test_default_web_client_uses_bolt_framework_logger(self): + app = App(token=self.valid_token, signing_secret=self.signing_secret, token_verification_enabled=False) + app.client.base_url = self.mock_api_server_base_url + + @app.action("a") + def listener(ack, client: WebClient): + assert client.logger == app.logger + ack() + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + def test_default_web_client_uses_bolt_app_custom_logger(self): + app = App( + token=self.valid_token, + signing_secret=self.signing_secret, + token_verification_enabled=False, + logger=self.test_logger, + ) + app.client.base_url = self.mock_api_server_base_url + + assert app.client.logger == app.logger + + @app.action("a") + def listener(ack, client: WebClient): + assert client.logger == app.logger + assert client.logger == self.test_logger + ack() + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + def test_custom_web_client_logger_is_used_instead_of_bolt_app_logger(self): + web_client = WebClient(token=self.valid_token, base_url=self.mock_api_server_base_url, logger=self.test_logger) + app = App( + client=web_client, + signing_secret=self.signing_secret, + ) + + @app.action("a") + def listener(ack, client: WebClient): + assert client.logger == self.test_logger + assert app.logger != self.test_logger + ack() + + request = self.build_valid_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + +block_actions_body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(block_actions_body))}" diff --git a/tests/scenario_tests/test_workflow_steps.py b/tests/scenario_tests/test_workflow_steps.py new file mode 100644 index 000000000..4c644f027 --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps.py @@ -0,0 +1,578 @@ +import json +import logging +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse, WebClient + +from slack_bolt import Ack, App, BoltRequest +from slack_bolt.workflows.step import Complete, Configure, Fail, Update +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.utils import remove_os_env_temporarily, restore_os_env + + +class TestWorkflowSteps: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_app(self, callback_id: str): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.step(callback_id=callback_id, edit=edit, save=save, execute=execute) + return app + + def build_process_before_response_app(self, callback_id: str): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.step( + callback_id=callback_id, + edit=[edit_ack, edit_lazy], + save=[save_ack, save_lazy], + execute=[execute_ack, execute_lazy], + ) + return app + + def test_edit(self): + app = self.build_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + app = self.build_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_edit_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + app = self.build_process_before_response_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_save(self): + app = self.build_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + app = self.build_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_save_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + app = self.build_process_before_response_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + app = self.build_app("copy_review") + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) + + app = self.build_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_execute_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) + + app = self.build_process_before_response_app("copy_review___") + response = app.dispatch(request) + assert response.status == 404 + + def test_custom_logger_propagation(self): + custom_logger = logging.getLogger(f"{__name__}-{time()}-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + + def verify_logger_is_properly_passed(ack: Ack, logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + ack() + + app.step( + callback_id="copy_review", + edit=verify_logger_is_properly_passed, + save=verify_logger_is_properly_passed, + execute=verify_logger_is_properly_passed, + ) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) + + +def edit_ack(ack: Ack): + ack() + + +def edit_lazy(step, configure: Configure): + assert step is not None + configure(blocks=[]) + + +def save_ack(ack: Ack): + ack() + + +def save_lazy(step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + update( + inputs={}, + outputs=[], + ) + + +def execute_ack(): + pass + + +def execute_lazy(step: dict, complete: Complete, fail: Fail): + assert step is not None + try: + complete(outputs={}) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests/test_workflow_steps_decorator_simple.py b/tests/scenario_tests/test_workflow_steps_decorator_simple.py new file mode 100644 index 000000000..bd48b5c5a --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps_decorator_simple.py @@ -0,0 +1,415 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse, WebClient + +from slack_bolt import Ack, App, BoltRequest +from slack_bolt.workflows.step import Complete, Configure, Fail, Update, WorkflowStep +from tests.mock_web_api_server import ( + assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWorkflowStepsDecorator: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = self.app.dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +copy_review_step = WorkflowStep.builder("copy_review") + + +@copy_review_step.edit +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +@copy_review_step.execute +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py new file mode 100644 index 000000000..4180dff59 --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py @@ -0,0 +1,523 @@ +import json +import logging +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse, WebClient + +from slack_bolt import Ack, App, BoltRequest +from slack_bolt.workflows.step import Complete, Configure, Fail, Update, WorkflowStep +from tests.mock_web_api_server import ( + assert_auth_test_count, + assert_received_request_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWorkflowStepsDecorator: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert_received_request_count(self, path="/workflows.stepCompleted", min_count=1, timeout=0.5) + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_logger_propagation(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + app.step(logger_test_step) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# +# The normal pattern tests +# + +# https://api.slack.com/tutorials/workflow-builder-steps + + +copy_review_step = WorkflowStep.builder("copy_review") + + +def noop_middleware(next): + return next() + + +@copy_review_step.edit(middleware=[noop_middleware]) +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save(middleware=[noop_middleware]) +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + ack() + + +pseudo_database = {} + + +@copy_review_step.execute(middleware=[noop_middleware]) +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) + + +# +# Logger propagation tests +# + +custom_logger = logging.getLogger(f"{__name__}-{time()}-logger-test") +custom_logger.setLevel(logging.INFO) +added_handler = logging.NullHandler() +custom_logger.addHandler(added_handler) +added_filter = logging.Filter() +custom_logger.addFilter(added_filter) + +logger_test_step = WorkflowStep.builder( + "copy_review", + base_logger=custom_logger, # to pass this logger to middleware / middleware matchers +) + + +def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + + +def logger_middleware(next, logger): + _verify_logger(logger) + next() + + +def logger_matcher(logger): + _verify_logger(logger) + return True + + +@logger_test_step.edit( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +def edit_for_logger_test(ack: Ack, logger: logging.Logger): + _verify_logger(logger) + ack() + + +@logger_test_step.save( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +def save_for_logger_test(ack: Ack, logger: logging.Logger): + _verify_logger(logger) + ack() + + +@logger_test_step.execute( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +def execute_for_logger_test(logger: logging.Logger): + _verify_logger(logger) diff --git a/tests/scenario_tests_async/__init__.py b/tests/scenario_tests_async/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/scenario_tests_async/test_app.py b/tests/scenario_tests_async/test_app.py new file mode 100644 index 000000000..6f3fb34f8 --- /dev/null +++ b/tests/scenario_tests_async/test_app.py @@ -0,0 +1,304 @@ +import asyncio +import logging +import ssl + +import pytest +from slack_sdk import WebClient +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.async_app import AsyncApp +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + restore_os_env(self.old_os_env) + + def non_coro_func(self, ack): + ack() + + def test_non_coroutine_func_listener(self): + app = AsyncApp(signing_secret="valid", token="xoxb-xxx") + with pytest.raises(BoltError): + app.action("a")(self.non_coro_func) + + async def simple_listener(self, ack): + await ack() + + def test_listener_registration_error(self): + app = AsyncApp(signing_secret="valid", token="xoxb-xxx") + with pytest.raises(BoltError): + app.action({"type": "invalid_type", "action_id": "a"})(self.simple_listener) + + # NOTE: We intentionally don't have this test in scenario_tests + # to avoid having async dependencies in the tests. + def test_invalid_client_type(self): + with pytest.raises(BoltError): + AsyncApp(signing_secret="valid", client=WebClient(token="xoxb-xxx")) + + # -------------------------- + # single team auth + # -------------------------- + + def test_valid_single_auth(self): + app = AsyncApp(signing_secret="valid", token="xoxb-xxx") + assert app != None + + def test_token_absence(self): + with pytest.raises(BoltError): + AsyncApp(signing_secret="valid", token=None) + with pytest.raises(BoltError): + AsyncApp(signing_secret="valid", token="") + + # -------------------------- + # multi teams auth + # -------------------------- + + def test_valid_multi_auth(self): + app = AsyncApp( + signing_secret="valid", + oauth_settings=AsyncOAuthSettings(client_id="111.222", client_secret="valid"), + ) + assert app != None + + def test_valid_multi_auth_oauth_flow(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + app = AsyncApp(signing_secret="valid", oauth_flow=oauth_flow) + assert app != None + + def test_valid_multi_auth_client_id_absence(self): + with pytest.raises(BoltError): + AsyncApp( + signing_secret="valid", + oauth_settings=AsyncOAuthSettings(client_id=None, client_secret="valid"), + ) + + def test_valid_multi_auth_secret_absence(self): + with pytest.raises(BoltError): + AsyncApp( + signing_secret="valid", + oauth_settings=AsyncOAuthSettings(client_id="111.222", client_secret=None), + ) + + def test_authorize_conflicts(self): + oauth_settings = AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + # no error with this + AsyncApp(signing_secret="valid", oauth_settings=oauth_settings) + + def authorize() -> AuthorizeResult: + return AuthorizeResult(enterprise_id="E111", team_id="T111") + + with pytest.raises(BoltError): + AsyncApp( + signing_secret="valid", + authorize=authorize, + oauth_settings=oauth_settings, + ) + + oauth_flow = AsyncOAuthFlow(settings=oauth_settings) + # no error with this + AsyncApp(signing_secret="valid", oauth_flow=oauth_flow) + + with pytest.raises(BoltError): + AsyncApp(signing_secret="valid", authorize=authorize, oauth_flow=oauth_flow) + + def test_installation_store_conflicts(self): + store1 = FileInstallationStore() + store2 = FileInstallationStore() + app = AsyncApp( + signing_secret="valid", + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=store1, + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = AsyncApp( + signing_secret="valid", + oauth_flow=AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=store1, + ) + ), + installation_store=store2, + ) + assert app.installation_store is store1 + + app = AsyncApp( + signing_secret="valid", + oauth_flow=AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="valid", + ) + ), + installation_store=store1, + ) + assert app.installation_store is store1 + + @pytest.mark.asyncio + async def test_proxy_ssl_for_respond(self): + 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_ctx, + ), + authorize=my_authorize, + ) + + result = {"called": False} + + @app.event("app_mention") + async def handle(context: AsyncBoltContext, respond): + assert context.respond.proxy == "http://proxy-host:9000/" + assert context.respond.ssl == ssl_ctx + assert respond.proxy == "http://proxy-host:9000/" + assert respond.ssl == ssl_ctx + result["called"] = True + + req = AsyncBoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") + response = await app.async_dispatch(req) + assert response.status == 200 + await asyncio.sleep(0.5) # wait a bit after auto ack() + assert result["called"] is True + + @pytest.mark.asyncio + async def test_argument_logger_propagation(self): + import time + + custom_logger = logging.getLogger(f"{__name__}-{time.time()}-async-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = AsyncApp( + signing_secret="valid", + client=AsyncWebClient( + token=self.valid_token, + base_url=self.mock_api_server_base_url, + ), + authorize=my_authorize, + logger=custom_logger, + ) + + result = {"called": False} + + def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + # TODO: this assertion fails only with codecov + # assert logger.handlers[-1] == custom_logger.handlers[-1] + assert logger.handlers[-1].name == custom_logger.handlers[-1].name + assert len(logger.filters) == len(custom_logger.filters) + # TODO: this assertion fails only with codecov + # assert logger.filters[-1] == custom_logger.filters[-1] + assert logger.filters[-1].name == custom_logger.filters[-1].name + + @app.use + async def global_middleware(logger, next): + _verify_logger(logger) + await next() + + async def listener_middleware(logger, next): + _verify_logger(logger) + await next() + + async def listener_matcher(logger): + _verify_logger(logger) + return True + + @app.event( + "app_mention", + middleware=[listener_middleware], + matchers=[listener_matcher], + ) + async def handle(logger: logging.Logger): + _verify_logger(logger) + result["called"] = True + + req = AsyncBoltRequest(body=app_mention_event_body, headers={}, mode="socket_mode") + response = await app.async_dispatch(req) + assert response.status == 200 + await asyncio.sleep(0.5) # wait a bit after auto ack() + assert result["called"] is True + + +async def my_authorize(): + return AuthorizeResult( + enterprise_id="E111", + team_id="T111", + ) + + +app_mention_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, +} diff --git a/tests/scenario_tests_async/test_app_actor_user_token.py b/tests/scenario_tests_async/test_app_actor_user_token.py new file mode 100644 index 000000000..0028096be --- /dev/null +++ b/tests/scenario_tests_async/test_app_actor_user_token.py @@ -0,0 +1,223 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, team_id: str = "T014GJXU940") -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps( + { + "team_id": team_id, + "enterprise_id": "E013Y3SHLAY", + "context_team_id": team_id, + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "W013QGS7BPF", + "display_as_bot": False, + "team": team_id, + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "W23456789", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + } + ) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_authorize_result(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + async def handle_events(context: AsyncBoltContext, say: AsyncSay): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T014GJXU940" + assert context.actor_user_id == "W013QGS7BPF" + + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid-actor-based" + assert context.authorize_result.user_scopes == ["search:read", "chat:write"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" + await say("What's up?") + + request = self.build_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 2) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_authorize_result_no_user_token(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + user_token_resolution="actor", + ), + ) + + @app.event("message") + async def handle_events(context: AsyncBoltContext, say: AsyncSay): + assert context.actor_enterprise_id == "E013Y3SHLAY" + assert context.actor_team_id == "T11111" + assert context.actor_user_id == "W013QGS7BPF" + + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id is None + assert context.authorize_result.user_token is None + assert context.authorize_result.user_scopes is None + await say("What's up?") + + request = self.build_request(team_id="T11111") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if team_id == "T0G9PQBBK": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + installed_at=datetime.datetime.now().timestamp(), + ) + if team_id == "T014GJXU940" and enterprise_id == "E013Y3SHLAY": + return Installation( + app_id="A111", + enterprise_id="E013Y3SHLAY", + team_id="T014GJXU940", + user_id="W11111", + user_token="xoxp-valid-actor-based", + user_scopes=["search:read", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + return None diff --git a/tests/scenario_tests_async/test_app_bot_only.py b/tests/scenario_tests_async/test_app_bot_only.py new file mode 100644 index 000000000..b350aeb2d --- /dev/null +++ b/tests/scenario_tests_async/test_app_bot_only.py @@ -0,0 +1,261 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAppBotOnly: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_bot_only_default_off(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=BotOnlyMemoryInstallationStore(), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 500 + assert response.body == "" + + @pytest.mark.asyncio + async def test_bot_only(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_bot_only_oauth_settings_conflicts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=oauth_settings, + installation_store_bot_only=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_bot_only_oauth_settings(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=oauth_settings_bot_only, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_bot_only_oauth_flow_conflicts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_flow=AsyncOAuthFlow(settings=oauth_settings), + installation_store_bot_only=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_bot_only_oauth_flow(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_flow=AsyncOAuthFlow(settings=oauth_settings_bot_only), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + +app_mention_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +class LegacyMemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError + + +oauth_settings = AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=False, +) + +oauth_settings_bot_only = AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=BotOnlyMemoryInstallationStore(), + installation_store_bot_only=True, +) diff --git a/tests/scenario_tests_async/test_app_custom_authorize.py b/tests/scenario_tests_async/test_app_custom_authorize.py new file mode 100644 index 000000000..2b0252645 --- /dev/null +++ b/tests/scenario_tests_async/test_app_custom_authorize.py @@ -0,0 +1,262 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.authorization.async_authorize import AsyncAuthorize +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAppCustomAuthorize: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_installation_store_only(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + ), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 2) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_installation_store_and_authorize(self): + installation_store = MemoryInstallationStore() + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=CustomAuthorize(installation_store), + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=installation_store, + ), + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_installation_store_and_func_authorize(self): + installation_store = MemoryInstallationStore() + + async def authorize(): + pass + + with pytest.raises(BoltError): + AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=installation_store, + ), + ) + + +app_mention_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class CustomAuthorize(AsyncAuthorize): + def __init__(self, installation_store: AsyncInstallationStore): + self.installation_store = installation_store + + async def __call__( + self, + *, + context: AsyncBoltContext, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str], + ) -> Optional[AuthorizeResult]: + bot_token: Optional[str] = None + user_token: Optional[str] = None + latest_installation: Optional[Installation] = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + is_enterprise_install=context.is_enterprise_install, + ) + this_user_installation: Optional[Installation] = None + if latest_installation is not None: + bot_token = latest_installation.bot_token # this still can be None + user_token = latest_installation.user_token # this still can be None + if latest_installation.user_id != user_id: + # First off, remove the user token as the installer is a different user + user_token = None + latest_installation.user_token = None + latest_installation.user_refresh_token = None + latest_installation.user_token_expires_at = None + latest_installation.user_scopes = [] + + # try to fetch the request user's installation + # to reflect the user's access token if exists + this_user_installation = await self.installation_store.async_find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=user_id, + is_enterprise_install=context.is_enterprise_install, + ) + if this_user_installation is not None: + user_token = this_user_installation.user_token + if latest_installation.bot_token is None: + # If latest_installation has a bot token, we never overwrite the value + bot_token = this_user_installation.bot_token + token: Optional[str] = bot_token or user_token + if token is None: + return None + try: + auth_test_api_response = await context.client.auth_test(token=token) + authorize_result = AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test_api_response, + bot_token=bot_token, + user_token=user_token, + ) + return authorize_result + except SlackApiError: + return None diff --git a/tests/scenario_tests_async/test_app_decorators.py b/tests/scenario_tests_async/test_app_decorators.py new file mode 100644 index 000000000..c54306bba --- /dev/null +++ b/tests/scenario_tests_async/test_app_decorators.py @@ -0,0 +1,110 @@ +from typing import Callable + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class NoopAsyncAck(AsyncAck): + async def __call__(self) -> BoltResponse: + pass + + +class TestAppDecorators: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + @pytest.mark.asyncio + async def test_decorators(self): + try: + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + + app = AsyncApp(signing_secret=self.signing_secret, client=self.web_client) + ack = NoopAsyncAck() + + @app.event("app_home_opened") + async def handle_events(body: dict): + assert body is not None + + await handle_events({}) + assert isinstance(handle_events, Callable) + + @app.message("hi") + async def handle_message_events(body: dict): + assert body is not None + + await handle_message_events({}) + assert isinstance(handle_message_events, Callable) + + @app.command("/hello") + async def handle_commands(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_commands(ack, {}) + assert isinstance(handle_commands, Callable) + + @app.shortcut("test-shortcut") + async def handle_shortcuts(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_shortcuts(ack, {}) + assert isinstance(handle_shortcuts, Callable) + + @app.action("some-action-id") + async def handle_actions(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_actions(ack, {}) + assert isinstance(handle_actions, Callable) + + @app.view("some-callback-id") + async def handle_views(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.options("some-id") + async def handle_views(ack: AsyncAck, body: dict): + assert body is not None + await ack() + + await handle_views(ack, {}) + assert isinstance(handle_views, Callable) + + @app.error + async def handle_errors(body: dict): + assert body is not None + + await handle_errors({}) + assert isinstance(handle_errors, Callable) + + @app.use + async def middleware(body, next): + assert body is not None + await next() + + async def next_func(): + pass + + await middleware({}, next_func) + assert isinstance(middleware, Callable) + + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(self.old_os_env) diff --git a/tests/scenario_tests_async/test_app_dispatch.py b/tests/scenario_tests_async/test_app_dispatch.py new file mode 100644 index 000000000..c483bed19 --- /dev/null +++ b/tests/scenario_tests_async/test_app_dispatch.py @@ -0,0 +1,68 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncAppDispatch: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_none_body(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + req = AsyncBoltRequest(body=None, headers={}, mode="http") + response = await app.async_dispatch(req) + # request verification failure + assert response.status == 401 + assert response.body == '{"error": "invalid request"}' + + req = AsyncBoltRequest(body=None, headers={}, mode="socket_mode") + response = await app.async_dispatch(req) + # request verification is skipped for Socket Mode + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @pytest.mark.asyncio + async def test_none_body_no_middleware(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ssl_check_enabled=False, + ignoring_self_events_enabled=False, + request_verification_enabled=False, + # token_verification_enabled=False, + url_verification_enabled=False, + ) + + req = AsyncBoltRequest(body=None, headers={}, mode="http") + response = await app.async_dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + req = AsyncBoltRequest(body=None, headers={}, mode="socket_mode") + response = await app.async_dispatch(req) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' diff --git a/tests/scenario_tests_async/test_app_installation_store.py b/tests/scenario_tests_async/test_app_installation_store.py new file mode 100644 index 000000000..16670f1b2 --- /dev/null +++ b/tests/scenario_tests_async/test_app_installation_store.py @@ -0,0 +1,167 @@ +import datetime +import json +import logging +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestApp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_authorize_result(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="secret", + installation_store=MemoryInstallationStore(), + ), + ) + + @app.event("app_mention") + async def handle_app_mention(context: AsyncBoltContext, say: AsyncSay): + assert context.authorize_result.bot_id == "BZYBOTHED" + assert context.authorize_result.bot_user_id == "W23456789" + assert context.authorize_result.bot_token == "xoxb-valid-2" + assert context.authorize_result.bot_scopes == ["commands", "chat:write"] + assert context.authorize_result.user_id == "W99999" + assert context.authorize_result.user_token == "xoxp-valid" + assert context.authorize_result.user_scopes == ["search:read"] + assert context.authorize_result.team_id == "T0G9PQBBK" + assert context.authorize_result.team == "Subarachnoid Workspace" + assert context.authorize_result.url == "https://subarachnoid.slack.com/" + await say("What's up?") + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 2) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + +app_mention_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], +} + + +class MemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> logging.Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) diff --git a/tests/scenario_tests_async/test_app_using_methods_in_class.py b/tests/scenario_tests_async/test_app_using_methods_in_class.py new file mode 100644 index 000000000..989de511e --- /dev/null +++ b/tests/scenario_tests_async/test_app_using_methods_in_class.py @@ -0,0 +1,249 @@ +import inspect +import json +from time import time +from typing import Callable + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAppUsingMethodsInClass: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def test_inspect_behaviors(self): + async def f(): + pass + + assert inspect.ismethod(f) is False + + class A: + async def b(self): + pass + + @classmethod + async def c(cls): + pass + + @staticmethod + async def d(): + pass + + a = A() + assert inspect.ismethod(a.b) is True + assert inspect.ismethod(A.c) is True + assert inspect.ismethod(A.d) is False + + async def run_app_and_verify(self, app: AsyncApp): + payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", + } + + timestamp, body = str(int(time())), f"payload={json.dumps(payload)}" + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_class_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_class_methods_uncommon_name(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method2) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_instance_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_callable_class(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + instance = CallableClass("Slackbot") + app.use(instance) + app.shortcut("test-shortcut")(instance.event_handler) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_instance_methods_uncommon_name_1(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method2) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_instance_methods_uncommon_name_2(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method3) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_static_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.static_middleware) + app.shortcut("test-shortcut")(AwesomeClass.static_method) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_invalid_arg_in_func(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.shortcut("test-shortcut")(top_level_function) + await self.run_app_and_verify(app) + + +class AwesomeClass: + def __init__(self, name: str): + self.name = name + + @classmethod + async def class_middleware(cls, next: Callable): + await next() + + async def instance_middleware(self, next: Callable): + await next() + + @staticmethod + async def static_middleware(next): + await next() + + @classmethod + async def class_method(cls, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>!") + + @classmethod + async def class_method2(xyz, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>!") + + async def instance_method(self, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>! My name is {self.name}") + + async def instance_method2(whatever, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + + text = "hello world" + + async def instance_method3(this, ack, logger, say): + await ack() + logger.debug(this.text) + await say(f"Hi there!") + + @staticmethod + async def static_method(context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>!") + + +class CallableClass: + def __init__(self, name: str): + self.name = name + + async def __call__(self, next: Callable): + await next() + + async def event_handler(self, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>! My name is {self.name}") + + +async def top_level_function(invalid_arg, ack, say): + assert invalid_arg is None + await ack() + await say("Hi") diff --git a/tests/async_scenario_tests/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py similarity index 61% rename from tests/async_scenario_tests/test_attachment_actions.py rename to tests/scenario_tests_async/test_attachment_actions.py index bccb42dd7..ea07e6ed0 100644 --- a/tests/async_scenario_tests/test_attachment_actions.py +++ b/tests/scenario_tests_async/test_attachment_actions.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -10,8 +9,9 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -21,23 +21,25 @@ class TestAsyncAttachmentActions: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -49,36 +51,56 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) @pytest.mark.asyncio async def test_mock_server_is_running(self): resp = await self.web_client.api_test() assert resp != None + @pytest.mark.asyncio + async def test_success_without_type(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("pick_channel_for_fun")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.attachment_action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -88,13 +110,16 @@ async def test_process_before_response(self): process_before_response=True, ) app.action( - {"callback_id": "pick_channel_for_fun", "type": "interactive_message",} + { + "callback_id": "pick_channel_for_fun", + "type": "interactive_message", + } )(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -108,39 +133,64 @@ async def test_process_before_response_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio - async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + async def test_failure_without_type(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) - app.action({"callback_id": "unknown", "type": "interactive_message",})( - simple_listener + app.action("unknown")(simple_listener) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_failure(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, ) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + app.action( + { + "callback_id": "unknown", + "type": "interactive_message", + } + )(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.attachment_action("unknown")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) -# https://api.slack.com/legacy/interactive-messages -payload = { +# https://docs.slack.dev/legacy/legacy-messaging/legacy-making-messages-interactive/ +body = { "type": "interactive_message", "actions": [ { @@ -188,9 +238,11 @@ async def test_failure_2(self): "trigger_id": "111.222.valid", } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body): +async def simple_listener(ack, body, payload, action): + assert body != payload + assert payload == action assert body["trigger_id"] == "111.222.valid" await ack() diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py new file mode 100644 index 000000000..9d6e3d4af --- /dev/null +++ b/tests/scenario_tests_async/test_authorize.py @@ -0,0 +1,272 @@ +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import is_event +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" + + +async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + auth_test = await client.auth_test(token=valid_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, + bot_token=valid_token, + ) + + +async def user_authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + auth_test = await client.auth_test(token=valid_user_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, + user_token=valid_user_token, + ) + + +async def error_authorize(enterprise_id, team_id, user_id): + assert enterprise_id == "E111" + assert team_id == "T111" + assert user_id == "W99999" + return None + + +class TestAsyncAuthorize: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_block_actions_request(self) -> AsyncBoltRequest: + timestamp = str(int(time())) + return AsyncBoltRequest(body=block_actions_raw_body, headers=self.build_headers(timestamp, block_actions_raw_body)) + + def build_message_changed_event_request(self) -> AsyncBoltRequest: + timestamp = str(int(time())) + raw_body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C2147483705", + "ts": "1358878755.000001", + "message": { + "type": "message", + "user": "U2147483697", + "text": "Hello, world!", + "ts": "1355517523.000005", + "edited": {"user": "U2147483697", "ts": "1358878755.000001"}, + }, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + ) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + @pytest.mark.asyncio + async def test_success(self): + app = AsyncApp( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_block_actions_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_failure(self): + app = AsyncApp( + client=self.web_client, + authorize=error_authorize, + signing_secret=self.signing_secret, + ) + app.block_action("a")(simple_listener) + + request = self.build_block_actions_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + assert await self.received_requests.get_async("/auth.test", 0) == 0 + + @pytest.mark.asyncio + async def test_bot_context_attributes(self): + app = AsyncApp( + client=self.web_client, + authorize=authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_bot_context_attributes) + + request = self.build_block_actions_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_user_context_attributes(self): + app = AsyncApp( + client=self.web_client, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_block_actions_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_user_context_attributes_msg_change(self): + async def skip_message_changed_events(body: dict, payload: dict, next_): + if is_event(body) and payload.get("type") == "message" and payload.get("subtype") == "message_changed": + return BoltResponse(status=200, body="as expected") + await next_() + + app = AsyncApp( + client=self.web_client, + before_authorize=skip_message_changed_events, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_block_actions_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + request = self.build_message_changed_event_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "as expected" + await assert_auth_test_count_async(self, 1) # should be skipped + + +block_actions_body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +block_actions_raw_body = f"payload={quote(json.dumps(block_actions_body))}" + + +async def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + await ack() + + +async def assert_bot_context_attributes(ack, context): + assert context["bot_id"] == "BZYBOTHED" + assert context["bot_user_id"] == "W23456789" + assert context["bot_token"] == "xoxb-valid" + assert context["token"] == "xoxb-valid" + assert context["user_id"] == "W99999" + assert context.get("user_token") is None + await ack() + + +async def assert_user_context_attributes(ack, context): + assert context.get("bot_id") is None + assert context.get("bot_user_id") is None + assert context.get("bot_token") is None + assert context["token"] == "xoxp-valid" + assert context["user_id"] == "W99999" + assert context["user_token"] == "xoxp-valid" + await ack() diff --git a/tests/async_scenario_tests/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py similarity index 54% rename from tests/async_scenario_tests/test_block_actions.py rename to tests/scenario_tests_async/test_block_actions.py index 3fa579e7b..6441b2e4b 100644 --- a/tests/async_scenario_tests/test_block_actions.py +++ b/tests/scenario_tests_async/test_block_actions.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -8,10 +7,14 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -21,23 +24,25 @@ class TestAsyncBlockActions: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -49,9 +54,7 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) @pytest.mark.asyncio async def test_mock_server_is_running(self): @@ -60,23 +63,81 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_action("a")(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_default_type(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action({"action_id": "a", "block_id": "b"})(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_default_type_no_block_id(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action({"action_id": "a"})(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_default_type_no_action_id(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action({"block_id": "b"})(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_default_type_unmatched_block_id(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action({"action_id": "a", "block_id": "bbb"})(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -90,7 +151,7 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -104,36 +165,42 @@ async def test_process_before_response_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.action("aaa")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.block_action("aaa")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) -payload = { +body = { "type": "block_actions", "user": { "id": "W111", @@ -170,9 +237,18 @@ async def test_failure_2(self): ], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body): +async def simple_listener(ack, body, payload, action, context: AsyncBoltContext): assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + assert context.bot_id == "BZYBOTHED" + assert context.bot_user_id == "W23456789" + assert context.bot_token == "xoxb-valid" + assert context.token == "xoxb-valid" + assert context.user_id == "W111" + assert context.user_token is None await ack() diff --git a/tests/scenario_tests_async/test_block_actions_respond.py b/tests/scenario_tests_async/test_block_actions_respond.py new file mode 100644 index 000000000..b95e1bbf3 --- /dev/null +++ b/tests/scenario_tests_async/test_block_actions_respond.py @@ -0,0 +1,176 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.async_app import AsyncRespond, AsyncAck, AsyncSay +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncBlockActionsRespond: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_success(self): + app = AsyncApp(client=self.web_client) + + @app.event("app_mention") + async def handle_app_mention_events(say: AsyncSay): + await say( + text="This is a section block with a button.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + }, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "action_id": "button", + }, + } + ], + ) + + @app.action("button") + async def handle_button_clicks(body: dict, ack: AsyncAck, respond: AsyncRespond): + await respond( + text="hey!", + thread_ts=body["message"]["ts"], + response_type="in_channel", + replace_original=False, + ) + await ack() + + # app_mention event + request = AsyncBoltRequest( + mode="socket_mode", + body={ + "team_id": "T0G9PQBBK", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@U111> hey", + "user": "U222", + "ts": "1678252212.229129", + "blocks": [ + { + "type": "rich_text", + "block_id": "BCCO", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U111"}, + {"type": "text", "text": " hey"}, + ], + } + ], + } + ], + "team": "T0G9PQBBK", + "channel": "C111", + "event_ts": "1678252212.229129", + }, + "type": "event_callback", + "event_id": "Ev04SPP46R6J", + "event_time": 1678252212, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", + }, + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + # block_actions request + request = AsyncBoltRequest( + mode="socket_mode", + body={ + "type": "block_actions", + "user": {"id": "U111"}, + "api_app_id": "A111", + "container": { + "type": "message", + "message_ts": "1678252213.679169", + "channel_id": "C111", + "is_ephemeral": False, + }, + "trigger_id": "4916855695380.xxx.yyy", + "team": {"id": "T0G9PQBBK"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "C111"}, + "message": { + "bot_id": "B111", + "type": "message", + "text": "This is a section block with a button.", + "user": "U222", + "ts": "1678252213.679169", + "app_id": "A111", + "blocks": [ + { + "type": "section", + "block_id": "8KR", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + "verbatim": False, + }, + "accessory": { + "type": "button", + "action_id": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + }, + } + ], + "team": "T0G9PQBBK", + }, + "state": {"values": {}}, + "response_url": "http://localhost:8888/webhook", + "actions": [ + { + "action_id": "button", + "block_id": "8KR", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "type": "button", + "action_ts": "1678252216.469172", + } + ], + }, + ) + response = await app.async_dispatch(request) + assert response.status == 200 diff --git a/tests/async_scenario_tests/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py similarity index 66% rename from tests/async_scenario_tests/test_block_suggestion.py rename to tests/scenario_tests_async/test_block_suggestion.py index a998a382a..2450957f4 100644 --- a/tests/async_scenario_tests/test_block_suggestion.py +++ b/tests/scenario_tests_async/test_block_suggestion.py @@ -1,4 +1,3 @@ -import asyncio import copy import json from time import time @@ -11,8 +10,9 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -22,23 +22,25 @@ class TestAsyncBlockSuggestion: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -50,15 +52,11 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) def build_valid_multi_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_multi_body, headers=self.build_headers(timestamp, raw_multi_body) - ) + return AsyncBoltRequest(body=raw_multi_body, headers=self.build_headers(timestamp, raw_multi_body)) @pytest.mark.asyncio async def test_mock_server_is_running(self): @@ -67,7 +65,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("es_a")(show_options) request = self.build_valid_request() @@ -75,11 +76,14 @@ async def test_success(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.block_suggestion("es_a")(show_options) request = self.build_valid_request() @@ -87,11 +91,14 @@ async def test_success_2(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_multi(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("mes_a")(show_multi_options) request = self.build_valid_multi_request() @@ -99,7 +106,7 @@ async def test_success_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -115,7 +122,7 @@ async def test_process_before_response(self): assert response.status == 200 assert response.body == expected_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_multi(self): @@ -131,49 +138,88 @@ async def test_process_before_response_multi(self): assert response.status == 200 assert response.body == expected_multi_response_body assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.options("mes_a")(show_multi_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.block_suggestion("mes_a")(show_multi_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_multi(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_multi_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.options("es_a")(show_options) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio + async def test_empty_options(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_options) + + request = self.build_valid_multi_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == """{"options": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_empty_option_groups(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_option_groups) + + request = self.build_valid_multi_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == """{"option_groups": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + await assert_auth_test_count_async(self, 1) -payload = { + +body = { "type": "block_suggestion", "user": { "id": "W111", @@ -246,16 +292,14 @@ async def test_failure_multi(self): }, } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -multi_payload = copy.deepcopy(payload) -multi_payload["block_id"] = "mes_b" -multi_payload["action_id"] = "mes_a" -raw_multi_body = f"payload={quote(json.dumps(multi_payload))}" +multi_body = copy.deepcopy(body) +multi_body["block_id"] = "mes_b" +multi_body["action_id"] = "mes_a" +raw_multi_body = f"payload={quote(json.dumps(multi_body))}" -response = { - "options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] -} +response = {"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]} expected_response_body = json.dumps(response) multi_response = { @@ -278,9 +322,25 @@ async def test_failure_multi(self): expected_multi_response_body = json.dumps(multi_response) -async def show_options(ack): +async def show_options(ack, body, payload, options): + assert body == options + assert payload == options await ack(response) -async def show_multi_options(ack): +async def show_multi_options(ack, body, payload, options): + assert body == options + assert payload == options await ack(multi_response) + + +async def show_empty_options(ack, body, payload, options): + assert body == options + assert payload == options + await ack(options=[]) + + +async def show_empty_option_groups(ack, body, payload, options): + assert body == options + assert payload == options + await ack(option_groups=[]) diff --git a/tests/async_scenario_tests/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py similarity index 57% rename from tests/async_scenario_tests/test_dialogs.py rename to tests/scenario_tests_async/test_dialogs.py index 3470be66a..110fca45a 100644 --- a/tests/async_scenario_tests/test_dialogs.py +++ b/tests/scenario_tests_async/test_dialogs.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -10,8 +9,9 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -21,23 +21,25 @@ class TestAsyncAttachmentActions: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -57,40 +59,68 @@ async def test_mock_server_is_running(self): assert resp != None @pytest.mark.asyncio - async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})( - handle_suggestion + async def test_success_without_type(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, ) - app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})( - handle_submission + app.options("dialog-callback-id")(handle_suggestion) + app.action("dialog-callback-id")(handle_submission_or_cancellation) + + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body != "" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + await assert_auth_test_count_async(self, 1) + + request = self.build_valid_request(submission_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + request = self.build_valid_request(cancellation_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_success(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, ) - app.action( - {"type": "dialog_cancellation", "callback_id": "dialog-callback-id"} - )(handle_cancellation) + app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})(handle_suggestion) + app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})(handle_submission) + app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"})(handle_cancellation) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.dialog_suggestion("dialog-callback-id")(handle_suggestion) app.dialog_submission("dialog-callback-id")(handle_submission) app.dialog_cancellation("dialog-callback-id")(handle_cancellation) @@ -100,19 +130,19 @@ async def test_success_2(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -121,34 +151,28 @@ async def test_process_before_response(self): signing_secret=self.signing_secret, process_before_response=True, ) - app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})( - handle_suggestion - ) - app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})( - handle_submission - ) - app.action( - {"type": "dialog_cancellation", "callback_id": "dialog-callback-id"} - )(handle_cancellation) + app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-id"})(handle_suggestion) + app.action({"type": "dialog_submission", "callback_id": "dialog-callback-id"})(handle_submission) + app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-id"})(handle_cancellation) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -166,106 +190,166 @@ async def test_process_before_response_2(self): assert response.status == 200 assert response.body == json.dumps(options_response) assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 3 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_suggestion_failure_without_type(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + app.options("dialog-callback-iddddd")(handle_suggestion) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_suggestion_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.dialog_suggestion("dialog-callback-iddddd")(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_suggestion_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"})(handle_suggestion) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_submission_failure_without_type(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) - app.options( - {"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"} - )(handle_suggestion) + app.action("dialog-callback-iddddd")(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_submission_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.dialog_submission("dialog-callback-iddddd")(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_submission_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request(suggestion_raw_body) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + app.action({"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"})(handle_submission) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_cancellation_failure_without_type(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) - app.action( - {"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"} - )(handle_submission) + app.action("dialog-callback-iddddd")(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_cancellation_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.dialog_cancellation("dialog-callback-iddddd")(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_cancellation_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(suggestion_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) - app.action( - {"type": "dialog_cancellation", "callback_id": "dialog-callback-iddddd"} - )(handle_cancellation) + app.action({"type": "dialog_cancellation", "callback_id": "dialog-callback-iddddd"})(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) -suggestion_payload = { +suggestion_body = { "type": "dialog_suggestion", "token": "verification_token", "action_ts": "1596603332.676855", @@ -283,7 +367,7 @@ async def test_cancellation_failure_2(self): "state": "Limo", } -submission_payload = { +submission_body = { "type": "dialog_submission", "token": "verification_token", "action_ts": "1596603334.328193", @@ -305,7 +389,7 @@ async def test_cancellation_failure_2(self): "state": "Limo", } -cancellation_payload = { +cancellation_body = { "type": "dialog_cancellation", "token": "verification_token", "action_ts": "1596603453.047897", @@ -322,9 +406,9 @@ async def test_cancellation_failure_2(self): "state": "Limo", } -suggestion_raw_body = f"payload={quote(json.dumps(suggestion_payload))}" -submission_raw_body = f"payload={quote(json.dumps(submission_payload))}" -cancellation_raw_body = f"payload={quote(json.dumps(cancellation_payload))}" +suggestion_raw_body = f"payload={quote(json.dumps(suggestion_body))}" +submission_raw_body = f"payload={quote(json.dumps(submission_body))}" +cancellation_raw_body = f"payload={quote(json.dumps(cancellation_body))}" async def handle_submission(ack): @@ -338,14 +422,27 @@ async def handle_submission(ack): "value": "UXD-342", }, {"label": "[FE-459] Remove the marquee tag", "value": "FE-459"}, - {"label": "[FE-238] Too many shades of gray in master CSS", "value": "FE-238",}, + { + "label": "[FE-238] Too many shades of gray in master CSS", + "value": "FE-238", + }, ] } -async def handle_suggestion(ack): +async def handle_suggestion(ack, body, payload, options): + assert body == options + assert payload == options await ack(options_response) -async def handle_cancellation(ack): +async def handle_cancellation(ack, body, payload, action): + assert body == action + assert payload == action + await ack() + + +async def handle_submission_or_cancellation(ack, body, payload, action): + assert body == action + assert payload == action await ack() diff --git a/tests/scenario_tests_async/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py new file mode 100644 index 000000000..fb0b9ddae --- /dev/null +++ b/tests/scenario_tests_async/test_error_handler.py @@ -0,0 +1,283 @@ +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.async_app import AsyncApp +from slack_bolt.error import BoltUnhandledRequestError +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server, + cleanup_mock_web_api_server, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncErrorHandler: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + # ---------------- + # utilities + # ---------------- + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + body = { + "type": "block_actions", + "user": { + "id": "W111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(body))}" + timestamp = str(int(time())) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + # ---------------- + # tests + # ---------------- + + @pytest.mark.asyncio + async def test_default(self): + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + + @pytest.mark.asyncio + async def test_custom(self): + async def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + @pytest.mark.asyncio + async def test_process_before_response_default(self): + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + + @pytest.mark.asyncio + async def test_process_before_response_custom(self): + async def error_handler(logger, payload, response): + logger.info(payload) + response.headers["x-test-result"] = ["1"] + + async def failing_listener(): + raise Exception("Something wrong!") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.error(error_handler) + app.action("a")(failing_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 500 + assert response.headers["x-test-result"] == ["1"] + + @pytest.mark.asyncio + async def test_unhandled_errors(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_process_before_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_no_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + + @app.middleware + async def broken_middleware(): + pass + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_process_before_response_no_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + + @app.middleware + async def broken_middleware(): + pass + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_middleware_errors(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + async def broken_middleware(next_): + assert next_ is not None + raise RuntimeError("Something wrong!") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 500 + assert response.body == "" + + @app.error + async def handle_errors(body, next_, error): + assert next_ is None + assert body is not None + assert isinstance(error, RuntimeError) + return BoltResponse(status=503, body="as expected") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 503 + assert response.body == "as expected" diff --git a/tests/scenario_tests_async/test_events.py b/tests/scenario_tests_async/test_events.py new file mode 100644 index 000000000..0cdaa0fac --- /dev/null +++ b/tests/scenario_tests_async/test_events.py @@ -0,0 +1,665 @@ +import asyncio +import json +import re +from functools import wraps +from random import random +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEvents: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test() + assert resp != None + + @pytest.mark.asyncio + async def test_app_mention(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_process_before_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_middleware_skip(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.event("app_mention", middleware=[skip_middleware])(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_simultaneous_requests(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("app_mention")(random_sleeper) + + request = self.build_valid_app_mention_request() + + times = 10 + tasks = [] + for i in range(times): + tasks.append(asyncio.ensure_future(app.async_dispatch(request))) + + await asyncio.sleep(5) + # Verifies all the tasks have been completed with 200 OK + assert sum([t.result().status for t in tasks if t.done()]) == 200 * times + + await assert_received_request_count_async(self, "/auth.test", times, 5) + await assert_received_request_count_async(self, "/chat.postMessage", times, 5) + + def build_valid_reaction_added_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(reaction_added_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_reaction_added(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("reaction_added")(whats_up) + + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_stable_auto_ack(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("reaction_added")(always_failing) + + for _ in range(10): + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_self_joined_left_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + @pytest.mark.asyncio + async def test_joined_left_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + @pytest.mark.asyncio + async def test_uninstallation_and_revokes(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("app_uninstalled") + async def handler1(say: AsyncSay): + await say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + async def handler2(say: AsyncSay): + await say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + # AsyncApp doesn't call auth.test when booting + await assert_auth_test_count_async(self, 0) + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + message_file_share_body = { + "token": "verification-token", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Here is your file!", + "files": [ + { + "id": "F111", + "created": 1610493713, + "timestamp": 1610493713, + "name": "test.png", + "title": "test.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 42706, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": False, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/test.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/test.png", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_360.png", + "thumb_360_w": 358, + "thumb_360_h": 360, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_480.png", + "thumb_480_w": 477, + "thumb_480_h": 480, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-8d3f9a6d4b/test_720.png", + "thumb_720_w": 716, + "thumb_720_h": 720, + "original_w": 736, + "original_h": 740, + "thumb_tiny": "xxx", + "permalink": "https://xxx.slack.com/files/U111/F111/test.png", + "permalink_public": "https://slack-files.com/T111-F111-3e534ef8ca", + "has_rich_preview": False, + } + ], + "upload": False, + "blocks": [ + { + "type": "rich_text", + "block_id": "gvM", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Here is your file!"}], + } + ], + } + ], + "user": "U111", + "display_as_bot": False, + "ts": "1610493715.001000", + "channel": "G111", + "subtype": "file_share", + "event_ts": "1610493715.001000", + "channel_type": "group", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + + @pytest.mark.asyncio + async def test_message_subtypes_0(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event({"type": "message", "subtype": "file_share"}) + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_message_subtypes_1(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event({"type": "message", "subtype": re.compile("file_.+")}) + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_message_subtypes_2(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event({"type": "message", "subtype": ["file_share"]}) + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_message_subtypes_3(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app._client = AsyncWebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("message") + async def handler1(event): + assert event["subtype"] == "file_share" + + timestamp, body = str(int(time())), json.dumps(self.message_file_share_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + # https://github.com/slackapi/bolt-python/issues/199 + @pytest.mark.asyncio + async def test_invalid_message_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def handle(): + pass + + # valid + app.event("message")(handle) + + with pytest.raises(ValueError): + app.event("message.channels")(handle) + with pytest.raises(ValueError): + app.event("message.groups")(handle) + with pytest.raises(ValueError): + app.event("message.im")(handle) + with pytest.raises(ValueError): + app.event("message.mpim")(handle) + + with pytest.raises(ValueError): + app.event(re.compile("message\\..*"))(handle) + + with pytest.raises(ValueError): + app.event({"type": "message.channels"})(handle) + with pytest.raises(ValueError): + app.event({"type": re.compile("message\\..*")})(handle) + + @pytest.mark.asyncio + async def test_context_generation(self): + body = { + "token": "verification-token", + "enterprise_id": "E222", # intentionally inconsistent for testing + "team_id": "T222", # intentionally inconsistent for testing + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610493715, + "authorizations": [ + { + "enterprise_id": "E333", + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-G111", + } + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + + @app.event("member_left_channel") + async def handle(context: AsyncBoltContext): + assert context.enterprise_id == "E333" + assert context.team_id is None + assert context.is_enterprise_install is True + assert context.user_id == "W111" + + timestamp, json_body = str(int(time())), json.dumps(body) + request: AsyncBoltRequest = AsyncBoltRequest(body=json_body, headers=self.build_headers(timestamp, json_body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_additional_decorators_1(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + + @my_decorator + @app.event("app_mention") + async def handle_app_mention(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(app_mention_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_additional_decorators_2(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + + @app.event("app_mention") + @my_decorator + async def handle_app_mention(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(app_mention_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + +def my_decorator(f): + @wraps(f) + async def wrap(*args, **kwargs): + await f(*args, **kwargs) + + return wrap + + +app_mention_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], +} + +reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + + +async def random_sleeper(body, say, payload, event): + assert body == app_mention_body + assert body["event"] == payload + assert payload == event + seconds = random() + 2 # 2-3 seconds + await asyncio.sleep(seconds) + await say(f"Sending this message after sleeping for {seconds} seconds") + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +async def skip_middleware(req, resp, next): + # return next() + pass + + +async def always_failing(): + raise Exception("Something wrong!") diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py new file mode 100644 index 000000000..9e1176c74 --- /dev/null +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -0,0 +1,527 @@ +import asyncio +from typing import Awaitable, Callable, Optional + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.async_app import ( + AsyncApp, + AsyncAssistant, + AsyncBoltContext, + AsyncBoltRequest, + AsyncSay, + AsyncSetStatus, + AsyncSetSuggestedPrompts, +) +from slack_bolt.middleware.async_middleware import AsyncMiddleware +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.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsAssistant: + 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 # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_thread_started(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + listener_called = asyncio.Event() + + @assistant.thread_started + async def start_thread( + say: AsyncSay, + set_suggested_prompts: AsyncSetSuggestedPrompts, + set_status: AsyncSetStatus, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert set_status.thread_ts == context.thread_ts + assert say.thread_ts == context.thread_ts + await say("Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] + ) + await set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}], + title="foo", + ) + 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_thread_context_changed(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + 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" + 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 + 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() + listener_called = asyncio.Event() + + @assistant.user_message + async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + try: + await set_status("is typing...") + await say("Here you are!") + listener_called.set() + except Exception as e: + await say(f"Oops, something went wrong (error: {e})") + + app.assistant(assistant) + + request = AsyncBoltRequest(body=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_user_message_with_assistant_thread(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + listener_called = asyncio.Event() + + @assistant.user_message + async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + try: + await set_status("is typing...") + await say("Here you are!") + listener_called.set() + except Exception as e: + await say(f"Oops, something went wrong (error: {e})") + + app.assistant(assistant) + + request = AsyncBoltRequest(body=user_message_event_body_with_assistant_thread, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + 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() + listener_called = asyncio.Event() + + @assistant.user_message + async def handle_user_message(): + listener_called.set() + + @assistant.bot_message + async def handle_bot_message(): + 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 + 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() + listener_called = asyncio.Event() + + @assistant.user_message + async def handle_user_message(): + listener_called.set() + + @assistant.bot_message + async def handle_bot_message(): + 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 + 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() + listener_called = asyncio.Event() + + @assistant.user_message + async def handle_user_message(): + listener_called.set() + + @assistant.bot_message + async def handle_bot_message(): + 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 + 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_conversation_kwargs_enabled=False) + listener_called = asyncio.Event() + + @app.event("assistant_thread_started") + async def start_thread(context: AsyncBoltContext): + assert context.get("set_status") is None + assert context.get("set_title") is None + assert context.get("set_suggested_prompts") is None + assert context.get("get_thread_context") is None + assert context.get("save_thread_context") is None + listener_called.set() + + 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_assistant_with_custom_listener_middleware(self): + app = AsyncApp(client=self.web_client) + assistant = AsyncAssistant() + listener_called = asyncio.Event() + middleware_called = asyncio.Event() + + class TestAsyncMiddleware(AsyncMiddleware): + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + 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 + assert req.context.get("set_suggested_prompts") is not None + assert req.context.get("get_thread_context") is not None + assert req.context.get("save_thread_context") is not None + return await next() + + @assistant.thread_started(middleware=[TestAsyncMiddleware()]) + async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPrompts, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + await say("Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] + ) + listener_called.set() + + @assistant.user_message(middleware=[TestAsyncMiddleware()]) + async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + await set_status("is typing...") + await say("Here you are!") + 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 + 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 + 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() + listener_called = asyncio.Event() + + class BlockingAsyncMiddleware(AsyncMiddleware): + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + # Intentionally not calling next() to short-circuit + return BoltResponse(status=200) + + @assistant.thread_started(middleware=[BlockingAsyncMiddleware()]) + async def start_thread(say: AsyncSay, context: AsyncBoltContext): + 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 asyncio.sleep(0.1) + assert not listener_called.is_set() + + +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, + } + ], + } + + +thread_started_event_body = build_payload( + { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C222", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + +thread_context_changed_event_body = build_payload( + { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C333", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + + +user_message_event_body = 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", + } +) + + +user_message_event_body_with_assistant_thread = 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": {"XXX": "YYY"}, + } +) + + +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", + "subtype": "message_changed", + "message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "assistant_app_thread": {"title": "When Slack was released?", "title_blocks": [], "artifacts": []}, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "D111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "im", + } +) + +channel_user_message_event_body = 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": "C111", + "event_ts": "1726133700.887259", + "channel_type": "channel", + } +) + +channel_message_changed_event_body = build_payload( + { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "C111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "channel", + } +) diff --git a/tests/scenario_tests_async/test_events_assistant_without_middleware.py b/tests/scenario_tests_async/test_events_assistant_without_middleware.py new file mode 100644 index 000000000..d72b09b04 --- /dev/null +++ b/tests/scenario_tests_async/test_events_assistant_without_middleware.py @@ -0,0 +1,288 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +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 ( + channel_message_changed_event_body, + channel_user_message_event_body, + message_changed_event_body, + thread_context_changed_event_body, + thread_started_event_body, + user_message_event_body, + user_message_event_body_with_assistant_thread, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsAssistantWithoutMiddleware: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + + try: + yield + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_thread_started(self): + app = AsyncApp(client=self.web_client) + listener_called = asyncio.Event() + + @app.event("assistant_thread_started") + async def handle_assistant_thread_started( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + await say("Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] + ) + listener_called.set() + + 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_thread_context_changed(self): + app = AsyncApp(client=self.web_client) + listener_called = asyncio.Event() + + @app.event("assistant_thread_context_changed") + async def handle_assistant_thread_context_changed( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + listener_called.set() + + request = AsyncBoltRequest(body=thread_context_changed_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_user_message(self): + app = AsyncApp(client=self.web_client) + listener_called = asyncio.Event() + + @app.message("") + async def handle_message( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + await set_status("is typing...") + await say("Here you are!") + 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 + 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) + listener_called = asyncio.Event() + + @app.message("") + async def handle_message( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == context.thread_ts + assert set_status is not None + assert set_title is not None + assert set_suggested_prompts is not None + assert get_thread_context is not None + assert save_thread_context is not None + try: + await set_status("is typing...") + await say("Here you are!") + 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 + 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) + listener_called = asyncio.Event() + + @app.event("message") + async def handle_message_event( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == 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 + listener_called.set() + + request = AsyncBoltRequest(body=message_changed_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_channel_user_message(self): + app = AsyncApp(client=self.web_client) + listener_called = asyncio.Event() + + @app.event("message") + async def handle_message_event( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == 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 + listener_called.set() + + request = AsyncBoltRequest(body=channel_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_channel_message_changed(self): + app = AsyncApp(client=self.web_client) + listener_called = asyncio.Event() + + @app.event("message") + async def handle_message_event( + say: AsyncSay, + set_status: AsyncSetStatus, + set_title: AsyncSetTitle, + set_suggested_prompts: AsyncSetSuggestedPrompts, + get_thread_context: AsyncGetThreadContext, + save_thread_context: AsyncSaveThreadContext, + context: AsyncBoltContext, + ): + assert context.thread_ts == "1726133698.626339" + assert say.thread_ts == 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 + listener_called.set() + + request = AsyncBoltRequest(body=channel_message_changed_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_assistant_events_conversation_kwargs_disabled(self): + 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): + assert context.get("set_status") is None + assert context.get("set_title") is None + assert context.get("set_suggested_prompts") is None + assert context.get("get_thread_context") is None + assert context.get("save_thread_context") is None + listener_called.set() + + 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 diff --git a/tests/scenario_tests_async/test_events_ignore_self.py b/tests/scenario_tests_async/test_events_ignore_self.py new file mode 100644 index 000000000..fa398a907 --- /dev/null +++ b/tests/scenario_tests_async/test_events_ignore_self.py @@ -0,0 +1,152 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsIgnoreSelf: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_self_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + request = AsyncBoltRequest(body=self_event, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + # The listener should not be executed + await assert_received_request_count_async(self, "/chat.postMessage", 0) + + @pytest.mark.asyncio + async def test_self_events_response_url(self): + app = AsyncApp(client=self.web_client) + app.event("message")(whats_up) + request = AsyncBoltRequest(body=response_url_message_event, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + # The listener should not be executed + await assert_received_request_count_async(self, "/chat.postMessage", 0) + + @pytest.mark.asyncio + async def test_not_self_events_response_url(self): + app = AsyncApp(client=self.web_client) + app.event("message")(whats_up) + request = AsyncBoltRequest(body=different_app_response_url_message_event, 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, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_self_events_disabled(self): + app = AsyncApp(client=self.web_client, ignoring_self_events_enabled=False) + app.event("reaction_added")(whats_up) + request = AsyncBoltRequest(body=self_event, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + +self_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + "channel_type": "channel", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + + +response_url_message_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! This is a reply using response_url.", + "ts": "1658282075.825129", + "bot_id": "BZYBOTHED", + "channel": "C111", + "event_ts": "1658282075.825129", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + + +different_app_response_url_message_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! This is a reply using response_url.", + "ts": "1658282075.825129", + "bot_id": "B_DIFFERENT_ONE", + "channel": "C111", + "event_ts": "1658282075.825129", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") diff --git a/tests/scenario_tests_async/test_events_org_apps.py b/tests/scenario_tests_async/test_events_org_apps.py new file mode 100644 index 000000000..e3706d9c6 --- /dev/null +++ b/tests/scenario_tests_async/test_events_org_apps.py @@ -0,0 +1,265 @@ +import asyncio +import json +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Installation +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class OrgAppInstallationStore(AsyncInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + assert enterprise_id == "E111" + assert team_id is None + return Installation( + enterprise_id="E111", + team_id=None, + user_id=user_id, + bot_token=valid_token, + bot_id="B111", + ) + + +class Result: + def __init__(self): + self.called = False + + +class TestAsyncOrgApps: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=None, base_url=mock_api_server_base_url) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_team_access_granted(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_granted", + "team_ids": ["T111", "T222"], + "event_ts": "111.222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + result = Result() + + @app.event("team_access_granted") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert result.called is True + + @pytest.mark.asyncio + async def test_team_access_revoked(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "team_access_revoked", + "team_ids": ["T111", "T222"], + "event_ts": "1606805732.987656", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805732, + } + + result = Result() + + @app.event("team_access_revoked") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert result.called is True + + @pytest.mark.asyncio + async def test_app_home_opened(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "app_home_opened", + "user": "W111", + "channel": "D111", + "tab": "messages", + "event_ts": "1606810927.510671", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810927, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + } + + result = Result() + + @app.event("app_home_opened") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert result.called is True + + @pytest.mark.asyncio + async def test_message(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=OrgAppInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "0186b75a-2ad4-4f36-8ccc-18608b0ac5d1", + "type": "message", + "text": "<@W222>", + "user": "W111", + "ts": "1606810819.000800", + "team": "T111", + "channel": "C111", + "event_ts": "1606810819.000800", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606810819, + "authed_users": [], + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": None, + "user_id": "W222", + "is_bot": True, + "is_enterprise_install": True, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", + } + + result = Result() + + @app.event("message") + async def handle_app_mention(body): + assert body == event_payload + result.called = True + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert result.called is True diff --git a/tests/scenario_tests_async/test_events_request_verification.py b/tests/scenario_tests_async/test_events_request_verification.py new file mode 100644 index 000000000..51ccfcd98 --- /dev/null +++ b/tests/scenario_tests_async/test_events_request_verification.py @@ -0,0 +1,116 @@ +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsRequestVerification: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_default(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.event("app_mention")(whats_up) + + timestamp, body = str(int(time())), json.dumps(app_mention_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_disabled(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + request_verification_enabled=False, + ) + app.event("app_mention")(whats_up) + + # request including invalid headers + expired = int(time()) - 3600 + timestamp, body = str(expired), json.dumps(app_mention_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + +app_mention_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T_INSTALLED", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], +} + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") diff --git a/tests/scenario_tests_async/test_events_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_events_shared_channels.py b/tests/scenario_tests_async/test_events_shared_channels.py new file mode 100644 index 000000000..ca43f979a --- /dev/null +++ b/tests/scenario_tests_async/test_events_shared_channels.py @@ -0,0 +1,543 @@ +import asyncio +import json +from random import random +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.authorization import AuthorizeResult +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +async def authorize(enterprise_id, team_id, client: AsyncWebClient): + assert enterprise_id == "E_INSTALLED" + assert team_id == "T_INSTALLED" + auth_test = await client.auth_test(token=valid_token) + return AuthorizeResult.from_auth_test_response( + auth_test_response=auth_test, + bot_token=valid_token, + ) + + +class TestAsyncEventsSharedChannels: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=None, base_url=mock_api_server_base_url) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(app_mention_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test(token=valid_token) + assert resp != None + + @pytest.mark.asyncio + async def test_app_mention(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_process_before_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + process_before_response=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + # no sleep here + await assert_received_request_count_async(self, "/chat.postMessage", 1, 0.01) + + @pytest.mark.asyncio + async def test_middleware_skip(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("app_mention", middleware=[skip_middleware])(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_simultaneous_requests(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("app_mention")(random_sleeper) + + request = self.build_valid_app_mention_request() + + times = 10 + tasks = [] + for i in range(times): + tasks.append(asyncio.ensure_future(app.async_dispatch(request))) + + await asyncio.sleep(5) + # Verifies all the tasks have been completed with 200 OK + assert sum([t.result().status for t in tasks if t.done()]) == 200 * times + + await assert_auth_test_count_async(self, times) + await assert_received_request_count_async(self, "/chat.postMessage", times) + + def build_valid_reaction_added_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(reaction_added_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_reaction_added(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_stable_auto_ack(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(always_failing) + + for _ in range(10): + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_self_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + self_event = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + timestamp, body = str(int(time())), json.dumps(self_event) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + # The listener should not be executed + await assert_received_request_count_async(self, "/chat.postMessage", 0) + + @pytest.mark.asyncio + async def test_self_joined_left_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + @pytest.mark.asyncio + async def test_joined_left_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T_INSTALLED", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + timestamp, body = str(int(time())), json.dumps(join_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + timestamp, body = str(int(time())), json.dumps(left_event_body) + request = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + @pytest.mark.asyncio + async def test_uninstallation_and_revokes(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + authorize=authorize, + ) + app._client = AsyncWebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("app_uninstalled") + async def handler1(say: AsyncSay): + await say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + async def handler2(say: AsyncSay): + await say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(app_uninstalled_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + timestamp, body = str(int(time())), json.dumps(tokens_revoked_body) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 200 + + # AsyncApp doesn't call auth.test when booting + await assert_auth_test_count_async(self, 0) + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + +app_mention_body = { + "token": "verification_token", + "team_id": "T_INSTALLED", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T_INSTALLED", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], +} + +reaction_added_body = { + "token": "verification_token", + "team_id": "T_SOURCE", + "enterprise_id": "E_SOURCE", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E_INSTALLED", + "team_id": "T_INSTALLED", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], +} + + +async def random_sleeper(body, say, payload, event): + assert body == app_mention_body + assert body["event"] == payload + assert payload == event + seconds = random() + 2 # 2-3 seconds + await asyncio.sleep(seconds) + await say(f"Sending this message after sleeping for {seconds} seconds") + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +async def skip_middleware(req, resp, next): + # return next() + pass + + +async def always_failing(): + raise Exception("Something wrong!") diff --git a/tests/scenario_tests_async/test_events_socket_mode.py b/tests/scenario_tests_async/test_events_socket_mode.py new file mode 100644 index 000000000..e3d3fc98f --- /dev/null +++ b/tests/scenario_tests_async/test_events_socket_mode.py @@ -0,0 +1,393 @@ +import asyncio +from random import random + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEvents: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def build_valid_app_mention_request(self) -> AsyncBoltRequest: + return AsyncBoltRequest(body=app_mention_body, mode="socket_mode") + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test() + assert resp != None + + @pytest.mark.asyncio + async def test_app_mention(self): + app = AsyncApp(client=self.web_client) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_process_before_response(self): + app = AsyncApp( + client=self.web_client, + process_before_response=True, + ) + app.event("app_mention")(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + # no sleep here + await assert_received_request_count_async(self, "/chat.postMessage", 1, 0.01) + + @pytest.mark.asyncio + async def test_middleware_skip(self): + app = AsyncApp(client=self.web_client) + app.event("app_mention", middleware=[skip_middleware])(whats_up) + + request = self.build_valid_app_mention_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_simultaneous_requests(self): + app = AsyncApp(client=self.web_client) + app.event("app_mention")(random_sleeper) + + request = self.build_valid_app_mention_request() + + times = 10 + tasks = [] + for i in range(times): + tasks.append(asyncio.ensure_future(app.async_dispatch(request))) + + await asyncio.sleep(5) + # Verifies all the tasks have been completed with 200 OK + assert sum([t.result().status for t in tasks if t.done()]) == 200 * times + + await assert_auth_test_count_async(self, times) + await assert_received_request_count_async(self, "/chat.postMessage", times) + + def build_valid_reaction_added_request(self) -> AsyncBoltRequest: + return AsyncBoltRequest(body=reaction_added_body, mode="socket_mode") + + @pytest.mark.asyncio + async def test_reaction_added(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_stable_auto_ack(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(always_failing) + + for _ in range(10): + request = self.build_valid_reaction_added_request() + response = await app.async_dispatch(request) + assert response.status == 200 + + @pytest.mark.asyncio + async def test_self_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + self_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W23456789", # bot_user_id + "item": { + "type": "message", + "channel": "C111", + "ts": "1599529504.000400", + }, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + request = AsyncBoltRequest(body=self_event, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + await assert_received_request_count_async(self, "/chat.postMessage", 0) + + @pytest.mark.asyncio + async def test_self_joined_left_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W23456789", # bot_user_id + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + request = AsyncBoltRequest(body=join_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + request = AsyncBoltRequest(body=left_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + @pytest.mark.asyncio + async def test_joined_left_events(self): + app = AsyncApp(client=self.web_client) + app.event("reaction_added")(whats_up) + + join_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_joined_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + "inviter": "U222", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + left_event_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "member_left_channel", + "user": "W111", # other user + "channel": "C111", + "channel_type": "C", + "team": "T111", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], + } + + @app.event("member_joined_channel") + async def handle_member_joined_channel(say): + await say("What's up?") + + @app.event("member_left_channel") + async def handle_member_left_channel(say): + await say("What's up?") + + request = AsyncBoltRequest(body=join_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + request = AsyncBoltRequest(body=left_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + @pytest.mark.asyncio + async def test_uninstallation_and_revokes(self): + app = AsyncApp(client=self.web_client) + app._client = AsyncWebClient(token="uninstalled-revoked", base_url=self.mock_api_server_base_url) + + @app.event("app_uninstalled") + async def handler1(say: AsyncSay): + await say(channel="C111", text="What's up?") + + @app.event("tokens_revoked") + async def handler2(say: AsyncSay): + await say(channel="C111", text="What's up?") + + app_uninstalled_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: AsyncBoltRequest = AsyncBoltRequest(body=app_uninstalled_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + + tokens_revoked_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["UXXXXXXXX"], "bot": ["UXXXXXXXX"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + } + + request: AsyncBoltRequest = AsyncBoltRequest(body=tokens_revoked_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + + # AsyncApp doesn't call auth.test when booting + await assert_auth_test_count_async(self, 0) + await assert_received_request_count_async(self, "/chat.postMessage", 2) + + +app_mention_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], +} + +reaction_added_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "W111", + "item": {"type": "message", "channel": "C111", "ts": "1599529504.000400"}, + "reaction": "heart_eyes", + "item_user": "W111", + "event_ts": "1599616881.000800", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authed_users": ["W111"], +} + + +async def random_sleeper(body, say, payload, event): + assert body == app_mention_body + assert body["event"] == payload + assert payload == event + seconds = random() + 2 # 2-3 seconds + await asyncio.sleep(seconds) + await say(f"Sending this message after sleeping for {seconds} seconds") + + +async def whats_up(body, say, payload, event): + assert body["event"] == payload + assert payload == event + await say("What's up?") + + +async def skip_middleware(req, resp, next): + # return next() + pass + + +async def always_failing(): + raise Exception("Something wrong!") diff --git a/tests/scenario_tests_async/test_events_token_revocations.py b/tests/scenario_tests_async/test_events_token_revocations.py new file mode 100644 index 000000000..0c079eede --- /dev/null +++ b/tests/scenario_tests_async/test_events_token_revocations.py @@ -0,0 +1,158 @@ +import asyncio +import json +from time import time +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.error import BoltError +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" + + +class MyInstallationStore(AsyncInstallationStore): + def __init__(self): + self.delete_bot_called = False + self.delete_installation_called = False + self.delete_all_called = False + + async def async_delete_bot(self, *, enterprise_id: Optional[str], team_id: Optional[str]) -> None: + self.delete_bot_called = True + + async def async_delete_installation( + self, *, enterprise_id: Optional[str], team_id: Optional[str], user_id: Optional[str] = None + ) -> None: + self.delete_installation_called = True + + async def async_delete_all(self, *, enterprise_id: Optional[str], team_id: Optional[str]): + self.delete_all_called = True + return await super().async_delete_all(enterprise_id=enterprise_id, team_id=team_id) + + +class TestEventsTokenRevocations: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=None, base_url=mock_api_server_base_url) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_no_installation_store(self): + self.web_client.token = valid_token + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + with pytest.raises(BoltError): + app.default_tokens_revoked_event_listener() + with pytest.raises(BoltError): + app.default_app_uninstalled_event_listener() + with pytest.raises(BoltError): + app.enable_token_revocation_listeners() + + @pytest.mark.asyncio + async def test_tokens_revoked(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "tokens_revoked", + "tokens": {"oauth": ["W111"], "bot": ["W222"]}, + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is False + + @pytest.mark.asyncio + async def test_app_uninstalled(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + installation_store=MyInstallationStore(), + ) + + event_payload = { + "token": "verification-token", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": {"type": "app_uninstalled"}, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1606805974, + } + + timestamp, body = str(int(time())), json.dumps(event_payload) + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + response = await app.async_dispatch(request) + assert response.status == 404 + + # Enable the built-in event listeners + app.enable_token_revocation_listeners() + response = await app.async_dispatch(request) + assert response.status == 200 + # auth.test API call must be skipped + await assert_auth_test_count_async(self, 0) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert app.installation_store.delete_bot_called is True + assert app.installation_store.delete_installation_called is True + assert app.installation_store.delete_all_called is True diff --git a/tests/scenario_tests_async/test_events_url_verification.py b/tests/scenario_tests_async/test_events_url_verification.py new file mode 100644 index 000000000..123fd3cce --- /dev/null +++ b/tests/scenario_tests_async/test_events_url_verification.py @@ -0,0 +1,82 @@ +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncEventsUrlVerification: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_default(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == """{"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"}""" + await assert_auth_test_count_async(self, 0) + + @pytest.mark.asyncio + async def test_disabled(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + url_verification_enabled=False, + ) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + assert response.body == """{"error": "unhandled request"}""" + await assert_auth_test_count_async(self, 0) + + +event_body = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", +} diff --git a/tests/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py new file mode 100644 index 000000000..ce9080e36 --- /dev/null +++ b/tests/scenario_tests_async/test_function.py @@ -0,0 +1,361 @@ +import asyncio +import json +import re +import time + +import pytest +from unittest.mock import Mock, MagicMock +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 ( + assert_received_request_count_async, + setup_mock_web_api_server_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncFunction: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request_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, 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): + resp = await self.web_client.api_test() + assert resp is not None + + @pytest.mark.asyncio + async def test_valid_callback_id_success(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(function_body) + 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, "/functions.completeSuccess", 1) + + @pytest.mark.asyncio + async def test_valid_callback_id_complete(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(complete_it) + + request = self.build_request_from_body(function_body) + 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, "/functions.completeSuccess", 1) + + @pytest.mark.asyncio + async def test_valid_callback_id_error(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse_error) + + request = self.build_request_from_body(function_body) + 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, "/functions.completeError", 1) + + @pytest.mark.asyncio + async def test_invalid_callback_id(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(wrong_id_function_body) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_auto_acknowledge_false_with_acknowledging(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False)(just_ack) + + request = self.build_request_from_body(function_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_auto_acknowledge_false_without_acknowledging(self, caplog, monkeypatch): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False)(just_no_ack) + request = self.build_request_from_body(function_body) + + 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=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 f"WARNING {just_no_ack.__name__} didn't call ack()" in caplog.text + + @pytest.mark.asyncio + async def test_function_handler_timeout(self, monkeypatch): + timeout = 5 + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse", auto_acknowledge=False, ack_timeout=timeout)(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=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 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): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(just_no_ack) + assert "WARNING" not in caplog.text + + timeout_argument_name = "ack_timeout" + kwargs = {timeout_argument_name: 5} + + callback_id = "reverse1" + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f'WARNING On @app.function("{callback_id}"), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused' + in caplog.text + ) + + callback_id = re.compile(r"hello \w+") + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f"WARNING On @app.function({callback_id}), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused" + in caplog.text + ) + + +function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + +wrong_id_function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "wrong_callback_id", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + + +async def reverse(body, event, client, context, complete, inputs): + assert body == function_body + assert event == function_body["event"] + assert inputs == function_body["event"]["inputs"] + assert context.function_execution_id == "Fx111" + assert complete.function_execution_id == "Fx111" + assert context.function_bot_access_token == "xwfp-valid" + assert context.client.token == "xwfp-valid" + assert client.token == "xwfp-valid" + assert complete.client.token == "xwfp-valid" + assert complete.has_been_called() is False + await complete( + outputs={"reverseString": "olleh"}, + ) + assert complete.has_been_called() is True + + +async def reverse_error(body, event, fail): + assert body == function_body + assert event == function_body["event"] + assert fail.function_execution_id == "Fx111" + assert fail.has_been_called() is False + await fail( + error="there was an error", + ) + assert fail.has_been_called() is True + + +async def complete_it(body, event, complete): + assert body == function_body + assert event == function_body["event"] + await complete( + outputs={}, + ) + + +async def just_ack(ack, body, event): + assert body == function_body + assert event == function_body["event"] + await ack() + + +async def just_no_ack(body, event): + assert body == function_body + assert event == function_body["event"] diff --git a/tests/scenario_tests_async/test_installation_store_authorize.py b/tests/scenario_tests_async/test_installation_store_authorize.py new file mode 100644 index 000000000..bef7d39e0 --- /dev/null +++ b/tests/scenario_tests_async/test_installation_store_authorize.py @@ -0,0 +1,154 @@ +import json +from time import time +from typing import Optional +from urllib.parse import quote + +import pytest +from slack_sdk.oauth.installation_store import Installation, Bot +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +valid_token = "xoxb-valid" +valid_user_token = "xoxp-valid" + + +class MyInstallationStore(AsyncInstallationStore): + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T111", + bot_token=valid_token, + bot_id="B111", + bot_user_id="W111", + bot_scopes=["commands"], + installed_at=time(), + ) + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return None + + +class TestAsyncInstallationStoreAuthorize: + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + timestamp = str(int(time())) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + @pytest.mark.asyncio + async def test_success(self): + app = AsyncApp( + client=self.web_client, + installation_store=MyInstallationStore(), + signing_secret=self.signing_secret, + ) + app.action("a")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + +body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +async def simple_listener(ack, body, payload, action): + assert body["trigger_id"] == "111.222.valid" + assert body["actions"][0] == payload + assert payload == action + assert action["action_id"] == "a" + await ack() diff --git a/tests/scenario_tests_async/test_lazy.py b/tests/scenario_tests_async/test_lazy.py new file mode 100644 index 000000000..8c4182f45 --- /dev/null +++ b/tests/scenario_tests_async/test_lazy.py @@ -0,0 +1,184 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncLazy: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + # ---------------- + # utilities + # ---------------- + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + body = { + "type": "block_actions", + "user": { + "id": "W111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], + } + raw_body = f"payload={quote(json.dumps(body))}" + timestamp = str(int(time())) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + # ---------------- + # tests + # ---------------- + + @pytest.mark.asyncio + async def test_lazy(self): + async def just_ack(ack): + await ack() + + async def async1(say): + await asyncio.sleep(0.3) + await say(text="lazy function 1") + + async def async2(say): + await asyncio.sleep(0.5) + await say(text="lazy function 2") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")( + ack=just_ack, + lazy=[async1, async2], + ) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_issue_545_context_copy_failure(self): + async def just_ack(ack): + await ack() + + async def async1(context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + await asyncio.sleep(0.3) + await say(text="lazy function 1") + + async def async2(context, say): + assert context.get("foo") == "FOO" + assert context.get("ssl_context") is None + await asyncio.sleep(0.5) + await say(text="lazy function 2") + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + async def set_ssl_context(context, next_): + import ssl + + context["foo"] = "FOO" + # This causes an error when starting lazy listener executions + 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) + # Traceback (most recent call last): + # File "/path/to/bolt-python/slack_bolt/app/async_app.py", line 585, in async_dispatch + # ] = await self._async_listener_runner.run( + # File "/path/to/bolt-python/slack_bolt/listener/asyncio_runner.py", line 167, in run + # self._start_lazy_function(lazy_func, request) + # File "/path/to/bolt-python/slack_bolt/listener/asyncio_runner.py", line 194, in _start_lazy_function + # copied_request = self._build_lazy_request(request, func_name) + # File "/path/to/bolt-python/slack_bolt/listener/asyncio_runner.py", line 201, in _build_lazy_request + # copied_request = create_copy(request) + # File "/path/to/bolt-python/slack_bolt/util/utils.py", line 48, in create_copy + # return copy.deepcopy(original) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 270, in _reconstruct + # state = deepcopy(state, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 146, in deepcopy + # y = copier(x, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 230, in _deepcopy_dict + # y[deepcopy(key, memo)] = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 172, in deepcopy + # y = _reconstruct(x, memo, *rv) + # File "/path/to/python/lib/python3.9/copy.py", line 296, in _reconstruct + # value = deepcopy(value, memo) + # File "/path/to/python/lib/python3.9/copy.py", line 161, in deepcopy + # rv = reductor(4) + # TypeError: cannot pickle 'SSLContext' object + + app.action("a")( + ack=just_ack, + lazy=[async1, async2], + ) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_received_request_count_async(self, "/chat.postMessage", 2) diff --git a/tests/scenario_tests_async/test_listener_middleware.py b/tests/scenario_tests_async/test_listener_middleware.py new file mode 100644 index 000000000..1b3d9b17d --- /dev/null +++ b/tests/scenario_tests_async/test_listener_middleware.py @@ -0,0 +1,110 @@ +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncListenerMiddleware: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + body = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + def build_request(self) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(self.body) + return AsyncBoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + @pytest.mark.asyncio + async def test_return_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut( + constraints="test-shortcut", + middleware=[listener_middleware_returning_response], + ) + async def handle(ack): + await ack() + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "listener middleware" + + @pytest.mark.asyncio + async def test_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.shortcut( + constraints="test-shortcut", + middleware=[just_next], + ) + async def handle(ack): + await ack() + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + + +async def listener_middleware_returning_response(): + return BoltResponse(status=200, body="listener middleware") + + +async def just_next(next): + await next() diff --git a/tests/scenario_tests_async/test_message.py b/tests/scenario_tests_async/test_message.py new file mode 100644 index 000000000..374760323 --- /dev/null +++ b/tests/scenario_tests_async/test_message.py @@ -0,0 +1,366 @@ +import asyncio +import json +import re +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessage: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request_from_body(self, message_body: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(message_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def build_request(self) -> AsyncBoltRequest: + return self.build_request_from_body(message_body) + + def build_request2(self) -> AsyncBoltRequest: + return self.build_request_from_body(message_body2) + + def build_request3(self) -> AsyncBoltRequest: + return self.build_request_from_body(message_body3) + + @pytest.mark.asyncio + async def test_string_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message("Hello")(whats_up) + + request = self.build_request() + 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, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_all_message_matching_1(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message("") + async def handle_all_new_messages(say): + await say("Thanks!") + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_all_message_matching_2(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.message() + async def handle_all_new_messages(say): + await say("Thanks!") + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_string_keyword_capturing(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_string_keyword_capturing2(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))(verify_matches) + + request = self.build_request2() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_string_keyword_capturing_multi_capture(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message(re.compile("([a-z|A-Z]{3,}-[0-9]+)"))(verify_matches_multi) + + request = self.build_request3() + 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, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_string_keyword_unmatched(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message("HELLO")(whats_up) + + request = self.build_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_regexp_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message(re.compile("He.lo"))(whats_up) + + request = self.build_request() + 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, "/chat.postMessage", 1) + + @pytest.mark.asyncio + async def test_regexp_keyword_unmatched(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.message(re.compile("HELLO"))(whats_up) + + request = self.build_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + # https://github.com/slackapi/bolt-python/issues/232 + @pytest.mark.asyncio + async def test_issue_232_message_listener_middleware(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + called = { + "first": False, + "second": False, + } + + async def this_should_be_skipped(): + return BoltResponse(status=500, body="failed") + + @app.message("first", middleware=[this_should_be_skipped]) + async def first(): + called["first"] = True + + @app.message("second", middleware=[]) + async def second(): + called["second"] = True + + request = self.build_request_from_body( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "This message should match the second listener only", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + } + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + await asyncio.sleep(0.3) + assert called["first"] is False + assert called["second"] is True + + @pytest.mark.asyncio + async def test_issue_561_matchers(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def just_fail(): + raise "This matcher should not be called!" + + @app.message("xxx", matchers=[just_fail]) + async def just_ack(): + raise "This listener should not be called!" + + request = self.build_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + +message_body = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "Hello World!", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "ezJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Hello World!"}], + } + ], + } + ], + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + "authed_users": ["W111"], +} + + +async def whats_up(body, say): + assert body == message_body + await say("What's up?") + + +message_body2 = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "We've received 103 messages from you!", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + "authed_users": ["W111"], +} + + +message_body3 = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "a8744611-0210-4f85-9f15-5faf7fb225c8", + "type": "message", + "text": "Please fix JIRA-1234, SCM-567 and BUG-169 as soon as you can!", + "user": "W111", + "ts": "1596183880.004200", + "team": "T111", + "channel": "C111", + "event_ts": "1596183880.004200", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1596183880, + "authed_users": ["W111"], +} + + +async def verify_matches(context, say): + assert context["matches"] == ("103", "you") + assert context.matches == ("103", "you") + await say("Thanks!") + + +async def verify_matches_multi(context, say, body, payload, message): + assert context["matches"] == ("JIRA-1234", "SCM-567", "BUG-169") + assert context.matches == ("JIRA-1234", "SCM-567", "BUG-169") + await say("Thanks!") diff --git a/tests/scenario_tests_async/test_message_bot.py b/tests/scenario_tests_async/test_message_bot.py new file mode 100644 index 000000000..50f29271c --- /dev/null +++ b/tests/scenario_tests_async/test_message_bot.py @@ -0,0 +1,210 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessage: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_string_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + result = {"call_count": 0} + + @app.message("Hi there!") + async def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(user_message_event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(bot_message_event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(classic_bot_message_event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert result["call_count"] == 3 + + +user_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "968c94da-c271-4f2a-8ec9-12a9985e5df4", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "W111", + "ts": "1610261659.001400", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "bN8", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hi there! Thanks for sharing the info!", + } + ], + } + ], + } + ], + "channel": "C111", + "event_ts": "1610261659.001400", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "bot_id": "B999", + "type": "message", + "text": "Hi there! Thanks for sharing the info!", + "user": "UB111", + "ts": "1610261539.000900", + "team": "T111", + "bot_profile": { + "id": "B999", + "deleted": False, + "name": "other-app", + "updated": 1607307935, + "app_id": "A222", + "icons": { + "image_36": "https://a.slack-edge.com/80588/img/plugins/app/bot_36.png", + "image_48": "https://a.slack-edge.com/80588/img/plugins/app/bot_48.png", + "image_72": "https://a.slack-edge.com/80588/img/plugins/app/service_72.png", + }, + "team_id": "T111", + }, + "channel": "C111", + "event_ts": "1610261539.000900", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev222", + "event_time": 1610261539, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} + +classic_bot_message_event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "bot_message", + "text": "Hi there! Thanks for sharing the info!", + "ts": "1610262363.001600", + "username": "classic-bot", + "bot_id": "B888", + "channel": "C111", + "event_ts": "1610262363.001600", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev333", + "event_time": 1610262363, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "UB222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests_async/test_message_changed.py b/tests/scenario_tests_async/test_message_changed.py new file mode 100644 index 000000000..66468f14a --- /dev/null +++ b/tests/scenario_tests_async/test_message_changed.py @@ -0,0 +1,128 @@ +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessageChanged: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_user_and_channel_id_in_context(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret, process_before_response=True) + + @app.event({"type": "message", "subtype": "message_changed"}) + async def handle_message_changed(context): + # These should come from the main event payload part + assert context.channel_id == "C111" + assert context.user_id == "U111" + # The following ones come from authorizations[0] + assert context.team_id == "T-auth" + assert context.enterprise_id == "E-auth" + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_changed", + "message": { + "type": "message", + "text": "updated message", + "user": "U111", + "team": "T111", + "edited": {"user": "U111", "ts": "1665102362.000000"}, + "blocks": [ + { + "type": "rich_text", + "block_id": "xwvU3", + "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "updated message"}]}], + } + ], + }, + "previous_message": { + "type": "message", + "text": "original message", + "user": "U222", + "team": "T111", + "ts": "1665102338.901939", + "blocks": [ + { + "type": "rich_text", + "block_id": "URf", + "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "original message"}]}], + } + ], + }, + "channel": "C111", + "hidden": True, + "ts": "1665102362.013600", + "event_ts": "1665102362.013600", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1665102362, + "authorizations": [ + { + "enterprise_id": "E-auth", + "team_id": "T-auth", + "user_id": "U-auth", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests_async/test_message_deleted.py b/tests/scenario_tests_async/test_message_deleted.py new file mode 100644 index 000000000..d5b6ba80c --- /dev/null +++ b/tests/scenario_tests_async/test_message_deleted.py @@ -0,0 +1,108 @@ +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessageDeleted: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_user_and_channel_id_in_context(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret, process_before_response=True) + + @app.event({"type": "message", "subtype": "message_deleted"}) + async def handle_message_deleted(context): + # These should come from the main event payload part + assert context.channel_id == "C111" + assert context.user_id == "U111" + # The following ones come from authorizations[0] + assert context.team_id == "T-auth" + assert context.enterprise_id == "E-auth" + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "message_deleted", + "previous_message": { + "type": "message", + "text": "Delete this message", + "user": "U111", + "team": "T111", + "ts": "1665368619.804829", + }, + "channel": "C111", + "hidden": True, + "deleted_ts": "1665368619.804829", + "event_ts": "1665368629.007100", + "ts": "1665368629.007100", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1665368629, + "authorizations": [ + { + "enterprise_id": "E-auth", + "team_id": "T-auth", + "user_id": "U-auth", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests_async/test_message_file_share.py b/tests/scenario_tests_async/test_message_file_share.py new file mode 100644 index 000000000..f156d9286 --- /dev/null +++ b/tests/scenario_tests_async/test_message_file_share.py @@ -0,0 +1,177 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessageFileShare: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_string_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + result = {"call_count": 0} + + @app.message("Hi there!") + async def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "xxx", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Hi there!", + "files": [ + { + "id": "F111", + "created": 1652227642, + "timestamp": 1652227642, + "name": "file.png", + "title": "file.png", + "mimetype": "image/png", + "filetype": "png", + "pretty_type": "PNG", + "user": "U111", + "editable": False, + "size": 92582, + "mode": "hosted", + "is_external": False, + "external_type": "", + "is_public": True, + "public_url_shared": False, + "display_as_bot": False, + "username": "", + "url_private": "https://files.slack.com/files-pri/T111-F111/file.png", + "url_private_download": "https://files.slack.com/files-pri/T111-F111/download/file.png", + "media_display_type": "unknown", + "thumb_64": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_64.png", + "thumb_80": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_80.png", + "thumb_360": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_360.png", + "thumb_360_w": 360, + "thumb_360_h": 115, + "thumb_480": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_480.png", + "thumb_480_w": 480, + "thumb_480_h": 153, + "thumb_160": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_160.png", + "thumb_720": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_720.png", + "thumb_720_w": 720, + "thumb_720_h": 230, + "thumb_800": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_800.png", + "thumb_800_w": 800, + "thumb_800_h": 255, + "thumb_960": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_960.png", + "thumb_960_w": 960, + "thumb_960_h": 306, + "thumb_1024": "https://files.slack.com/files-tmb/T111-F111-f820f29515/file_1024.png", + "thumb_1024_w": 1024, + "thumb_1024_h": 327, + "original_w": 1134, + "original_h": 362, + "thumb_tiny": "AwAPADCkCAOcUEj0zTaKAHZHpT9oxwR+VRVMBQA0r3yPypu0f3v0p5yBTCcmmI//2Q==", + "permalink": "https://xxx.slack.com/files/U111/F111/file.png", + "permalink_public": "https://slack-files.com/T111-F111-faecabecf7", + "has_rich_preview": False, + } + ], + "upload": False, + "user": "U111", + "display_as_bot": False, + "ts": "1652227646.593159", + "blocks": [ + { + "type": "rich_text", + "block_id": "ba4", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Hi there!"}], + } + ], + } + ], + "client_msg_id": "ca088267-717f-41a8-9db8-c98ae14ad6a0", + "channel": "C111", + "subtype": "file_share", + "event_ts": "1652227646.593159", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev03EGJQAVMM", + "event_time": 1652227646, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T111", + "user_id": "U222", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", +} diff --git a/tests/scenario_tests_async/test_message_thread_broadcast.py b/tests/scenario_tests_async/test_message_thread_broadcast.py new file mode 100644 index 000000000..c15bbdc99 --- /dev/null +++ b/tests/scenario_tests_async/test_message_thread_broadcast.py @@ -0,0 +1,127 @@ +import asyncio +import json +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncMessageThreadBroadcast: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request(self, event_payload: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time())), json.dumps(event_payload) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_string_keyword(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + result = {"call_count": 0} + + @app.message("Hi there!") + async def handle_messages(event, logger): + logger.info(event) + result["call_count"] = result["call_count"] + 1 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + request = self.build_request(event_payload) + response = await app.async_dispatch(request) + assert response.status == 200 + + await assert_auth_test_count_async(self, 1) + await asyncio.sleep(0.1) # wait a bit after auto ack() + assert result["call_count"] == 2 + + +event_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "thread_broadcast", + "text": "Hi there!", + "user": "U111", + "ts": "1633670813.007500", + "thread_ts": "1633663824.000500", + "root": { + "client_msg_id": "111-222-333-444-555", + "type": "message", + "text": "Write in the thread :bow:", + "user": "U111", + "ts": "1633663824.000500", + "team": "T111", + "thread_ts": "1633663824.000500", + "reply_count": 17, + "reply_users_count": 1, + "latest_reply": "1633670813.007500", + "reply_users": ["U111"], + "is_locked": False, + }, + "client_msg_id": "111-222-333-444-666", + "channel": "C111", + "event_ts": "1633670813.007500", + "channel_type": "channel", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1610261659, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "1-message-T111-C111", +} diff --git a/tests/scenario_tests_async/test_middleware.py b/tests/scenario_tests_async/test_middleware.py new file mode 100644 index 000000000..f8dfe9623 --- /dev/null +++ b/tests/scenario_tests_async/test_middleware.py @@ -0,0 +1,233 @@ +import json +import asyncio +from time import time +from typing import Callable, Awaitable, Optional + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.listener.async_listener import AsyncCustomListener +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher +from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import is_shortcut +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +# Note that async middleware system does not support instance methods n a class. +class TestAsyncMiddleware: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def build_request(self) -> AsyncBoltRequest: + body = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + timestamp, body = str(int(time())), json.dumps(body) + return AsyncBoltRequest( + body=body, + headers={ + "content-type": ["application/json"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + + @pytest.mark.asyncio + async def test_no_next_call(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(no_next) + app.shortcut("test-shortcut")(just_ack) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_next_call(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(just_next) + app.shortcut("test-shortcut")(just_ack) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_decorator_next_call(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + async def just_next(next): + await next() + + app.shortcut("test-shortcut")(just_ack) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_next_underscore_call(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.use(just_next_) + app.shortcut("test-shortcut")(just_ack) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_decorator_next_underscore_call(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.middleware + async def just_next_(next_): + await next_() + + app.shortcut("test-shortcut")(just_ack) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + assert response.body == "acknowledged!" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_lazy_listener_middleware(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + unmatch_middleware = LazyListenerStarter("xxxx") + app.use(unmatch_middleware) + + response = await app.async_dispatch(self.build_request()) + assert response.status == 404 + + my_middleware = LazyListenerStarter("test-shortcut") + app.use(my_middleware) + response = await app.async_dispatch(self.build_request()) + assert response.status == 200 + count = 0 + while count < 20 and my_middleware.lazy_called is False: + await asyncio.sleep(0.05) + assert my_middleware.lazy_called is True + + +async def just_ack(ack): + await ack("acknowledged!") + + +async def no_next(): + pass + + +async def just_next(next): + await next() + + +async def just_next_(next_): + await next_() + + +class LazyListenerStarter(AsyncMiddleware): + lazy_called: bool + callback_id: str + + def __init__(self, callback_id: str): + self.lazy_called = False + self.callback_id = callback_id + + async def lazy_listener(self): + self.lazy_called = True + + async def async_process( + self, *, req: AsyncBoltRequest, resp: BoltResponse, next: Callable[[], Awaitable[BoltResponse]] + ) -> Optional[BoltResponse]: + async def is_target(payload: dict): + return payload.get("callback_id") == self.callback_id + + if is_shortcut(req.body): + listener = AsyncCustomListener( + app_name="test-app", + ack_function=just_ack, + lazy_functions=[self.lazy_listener], + matchers=[ + AsyncCustomListenerMatcher( + app_name="test-app", + func=is_target, + ) + ], + middleware=[], + base_logger=req.context.logger, + ) + if await listener.async_matches(req=req, resp=resp): + listener_runner: AsyncioListenerRunner = req.context.listener_runner + response = await listener_runner.run( + request=req, + response=resp, + listener_name="test", + listener=listener, + ) + if response is not None: + return response + await next() diff --git a/tests/async_scenario_tests/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py similarity index 70% rename from tests/async_scenario_tests/test_shortcut.py rename to tests/scenario_tests_async/test_shortcut.py index ac2fe3eb1..bd3c595eb 100644 --- a/tests/async_scenario_tests/test_shortcut.py +++ b/tests/scenario_tests_async/test_shortcut.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -10,8 +9,9 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -21,23 +21,25 @@ class TestAsyncShortcut: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -56,67 +58,91 @@ async def test_mock_server_is_running(self): resp = await self.web_client.api_test() assert resp != None + # NOTE: This is a compatible behavior with Bolt for JS @pytest.mark.asyncio - async def test_success_global(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + async def test_success_both_global_and_message(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) - assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_success_global(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.shortcut("test-shortcut")(simple_listener) + + request = self.build_valid_request(global_shortcut_raw_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_global_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.global_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_message(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - app.shortcut({"type": "message_action", "callback_id": "test-shortcut"})( - simple_listener + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, ) + app.shortcut({"type": "message_action", "callback_id": "test-shortcut"})(simple_listener) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_message_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message_shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_global(self): @@ -130,41 +156,47 @@ async def test_process_before_response_global(self): request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.shortcut("another-one")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.global_shortcut("another-one")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 3 + await assert_auth_test_count_async(self, 1) -global_shortcut_payload = { +global_shortcut_body = { "type": "shortcut", "token": "verification_token", "action_ts": "111.111", @@ -179,7 +211,7 @@ async def test_failure_2(self): "trigger_id": "111.111.xxxxxx", } -message_shortcut_payload = { +message_shortcut_body = { "type": "message_action", "token": "verification_token", "action_ts": "1583637157.207593", @@ -220,9 +252,11 @@ async def test_failure_2(self): "response_url": "https://hooks.slack.com/app/T111/111/xxx", } -global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_payload))}" -message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_payload))}" +global_shortcut_raw_body = f"payload={quote(json.dumps(global_shortcut_body))}" +message_shortcut_raw_body = f"payload={quote(json.dumps(message_shortcut_body))}" -async def simple_listener(ack): +async def simple_listener(ack, body, payload, shortcut): + assert body == shortcut + assert payload == shortcut await ack() diff --git a/tests/async_scenario_tests/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py similarity index 58% rename from tests/async_scenario_tests/test_slash_command.py rename to tests/scenario_tests_async/test_slash_command.py index dcf0bd24b..918ccba87 100644 --- a/tests/async_scenario_tests/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -1,16 +1,16 @@ -import asyncio import json from time import time import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -20,23 +20,25 @@ class TestAsyncSlashCommand: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -47,7 +49,7 @@ def build_headers(self, timestamp: str, body: str): } def build_valid_request(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(slash_command_payload) + timestamp, body = str(int(time())), json.dumps(slash_command_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) @pytest.mark.asyncio @@ -57,13 +59,16 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.command("/hello-world")(commander) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -77,23 +82,55 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.command("/another-one")(commander) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + 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_payload = ( +slash_command_body = ( "token=verification_token" "&team_id=T111" "&team_domain=test-domain" @@ -110,5 +147,7 @@ async def test_failure(self): ) -async def commander(ack): +async def commander(ack, body, payload, command): + assert body == command + assert payload == command await ack() diff --git a/tests/async_scenario_tests/test_ssl_check.py b/tests/scenario_tests_async/test_ssl_check.py similarity index 78% rename from tests/async_scenario_tests/test_ssl_check.py rename to tests/scenario_tests_async/test_ssl_check.py index 2ea86f98f..ef32bc0dd 100644 --- a/tests/async_scenario_tests/test_ssl_check.py +++ b/tests/scenario_tests_async/test_ssl_check.py @@ -1,15 +1,14 @@ -import asyncio from time import time import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest -from slack_sdk.signature import SignatureVerifier -from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,23 +18,25 @@ class TestAsyncSSLCheck: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) @pytest.mark.asyncio diff --git a/tests/async_scenario_tests/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py similarity index 67% rename from tests/async_scenario_tests/test_view_closed.py rename to tests/scenario_tests_async/test_view_closed.py index 831712486..1b86d22db 100644 --- a/tests/async_scenario_tests/test_view_closed.py +++ b/tests/scenario_tests_async/test_view_closed.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -10,8 +9,9 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + assert_auth_test_count_async, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -21,23 +21,25 @@ class TestAsyncViewClosed: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -49,9 +51,7 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) @pytest.mark.asyncio async def test_mock_server_is_running(self): @@ -60,23 +60,29 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view({"type": "view_closed", "callback_id": "view-id"})(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.view_closed("view-id")(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -90,49 +96,58 @@ async def test_process_before_response(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view({"type": "view_closed", "callback_id": "view-idddd"})(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_2(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) app.view_closed("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 2 + await assert_auth_test_count_async(self, 1) -payload = { +body = { "type": "view_closed", "team": { "id": "T111", @@ -156,7 +171,10 @@ async def test_failure_2(self): { "type": "input", "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, "optional": False, "element": {"type": "plain_text_input", "action_id": "maBWU"}, } @@ -165,11 +183,20 @@ async def test_failure_2(self): "callback_id": "view-id", "state": {"values": {}}, "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, + "title": { + "type": "plain_text", + "text": "My App", + }, "clear_on_close": False, "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "previous_view_id": None, "root_view_id": "V111", "app_id": "A111", @@ -180,9 +207,11 @@ async def test_failure_2(self): "response_urls": [], } -raw_body = f"payload={quote(json.dumps(payload))}" +raw_body = f"payload={quote(json.dumps(body))}" -async def simple_listener(ack, body): - assert body["view"]["private_metadata"] == "This is for you!" +async def simple_listener(ack, body, payload, view): + assert body["view"] == view + assert payload == view + assert view["private_metadata"] == "This is for you!" await ack() diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py new file mode 100644 index 000000000..6511243fa --- /dev/null +++ b/tests/scenario_tests_async/test_view_submission.py @@ -0,0 +1,345 @@ +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +body = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": {"values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}}}, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +raw_body = f"payload={quote(json.dumps(body))}" + + +def simple_listener(ack, body, payload, view): + assert body["trigger_id"] == "111.222.valid" + assert body["view"] == payload + assert payload == view + assert view["private_metadata"] == "This is for you!" + ack() + + +response_url_payload_body = { + "type": "view_submission", + "team": {"id": "T111", "domain": "test-test-test"}, + "user": { + "id": "U111", + "username": "test-test-test", + "name": "test-test-test", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [], + "callback_id": "view-id", + "state": {}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [ + { + "block_id": "b", + "action_id": "a", + "channel_id": "C111", + "response_url": "http://localhost:8888/webhook", + } + ], + "is_enterprise_install": False, +} + + +raw_response_url_body = f"payload={quote(json.dumps(response_url_payload_body))}" + + +connect_channel_payload = { + "type": "view_submission", + "team": { + "id": "T-other-side", + "domain": "other-side", + "enterprise_id": "E-other-side", + "enterprise_name": "Kaz Sandbox Org", + }, + "user": {"id": "W111", "username": "kaz", "name": "kaz", "team_id": "T-other-side"}, + "api_app_id": "A1111", + "token": "legacy-fixed-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V11111", + "team_id": "T-other-side", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "zniAM", + "label": {"type": "plain_text", "text": "Label"}, + "element": { + "type": "plain_text_input", + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "qEJr", + }, + } + ], + "private_metadata": "", + "callback_id": "view-id", + "state": {"values": {"zniAM": {"qEJr": {"type": "plain_text_input", "value": "Hi there!"}}}}, + "hash": "1664950703.CmTS8F7U", + "title": {"type": "plain_text", "text": "My App"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "root_view_id": "V00000", + "app_id": "A1111", + "external_id": "", + "app_installed_team_id": "T-installed-workspace", + "bot_id": "B1111", + }, + "enterprise": {"id": "E-other-side", "name": "Kaz Sandbox Org"}, +} + +connect_channel_body = f"payload={quote(json.dumps(connect_channel_payload))}" + + +class TestAsyncViewSubmission: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self, body: str = raw_body) -> AsyncBoltRequest: + timestamp = str(int(time())) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test() + assert resp != None + + @pytest.mark.asyncio + async def test_success(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.view("view-id")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_success_2(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.view_submission("view-id")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_process_before_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.view("view-id")(simple_listener) + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_failure(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + app.view("view-idddd")(simple_listener) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_failure_2(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + app.view_submission("view-idddd")(simple_listener) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_response_urls(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.view("view-id") + async def check(ack, respond): + await respond("Hi") + await ack() + + request = self.build_valid_request(raw_response_url_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_connected_channels(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.view("view-id")(verify_connected_channel) + + request = self.build_valid_request(body=connect_channel_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + +async def simple_listener(ack, body, payload, view): + assert body["trigger_id"] == "111.222.valid" + assert body["view"] == payload + assert payload == view + assert view["private_metadata"] == "This is for you!" + await ack() + + +async def verify_connected_channel(ack, context): + assert context.team_id == "T-installed-workspace" + await ack() diff --git a/tests/scenario_tests_async/test_web_client_customization.py b/tests/scenario_tests_async/test_web_client_customization.py new file mode 100644 index 000000000..8ed78b2c3 --- /dev/null +++ b/tests/scenario_tests_async/test_web_client_customization.py @@ -0,0 +1,196 @@ +import json +import logging +import os +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.http_retry.builtin_async_handlers import AsyncConnectionErrorRetryHandler, AsyncRateLimitErrorRetryHandler +from slack_sdk.signature import SignatureVerifier + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.async_app import AsyncApp +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWebClientCustomization: + valid_token = "xoxb-valid" + signing_secret = "secret" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + test_logger = logging.getLogger("test.logger") + 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 # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_valid_request(self) -> AsyncBoltRequest: + timestamp = str(int(time())) + return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + + @pytest.mark.asyncio + async def test_web_client_customization(self): + if os.environ.get("BOLT_PYTHON_CODECOV_RUNNING") == "1": + # Traceback (most recent call last): + # File "/opt/hostedtoolcache/Python/3.11.2/x64/lib/python3.11/site-packages/slack_sdk-3.20.0-py3.11.egg/slack_sdk/web/async_internal_utils.py", line 151, in _request_with_session + # if await handler.can_retry_async( + # ^^^^^^^^^^^^^^^^^^^^^^^^ + # TypeError: AsyncRetryHandler.can_retry_async() missing 1 required positional argument: 'self' + return + + self.web_client.retry_handlers = [ + AsyncConnectionErrorRetryHandler(), + AsyncRateLimitErrorRetryHandler(), + ] + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + @app.action("a") + async def listener(ack, client): + assert len(client.retry_handlers) == 2 + await ack() + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + def test_web_client_logger_is_default_app_logger(self): + app = AsyncApp(token=self.valid_token, signing_secret=self.signing_secret) + assert app.client.logger == app.logger + + def test_web_client_logger_is_app_logger(self): + app = AsyncApp(token=self.valid_token, signing_secret=self.signing_secret, logger=self.test_logger) + assert app.client.logger == app.logger + assert app.client.logger == self.test_logger + + @pytest.mark.asyncio + async def test_default_web_client_uses_bolt_framework_logger(self): + app = AsyncApp(token=self.valid_token, signing_secret=self.signing_secret) + app.client.base_url = self.mock_api_server_base_url + + @app.action("a") + async def listener(ack, client: AsyncWebClient): + assert client.logger == app.logger + await ack() + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_default_web_client_uses_bolt_app_custom_logger(self): + app = AsyncApp( + token=self.valid_token, + signing_secret=self.signing_secret, + logger=self.test_logger, + ) + app.client.base_url = self.mock_api_server_base_url + + assert app.client.logger == app.logger + + @app.action("a") + async def listener(ack, client: AsyncWebClient): + assert client.logger == app.logger + assert client.logger == self.test_logger + await ack() + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_custom_web_client_logger_is_used_instead_of_bolt_app_logger(self): + web_client = AsyncWebClient(token=self.valid_token, base_url=self.mock_api_server_base_url, logger=self.test_logger) + app = AsyncApp( + client=web_client, + signing_secret=self.signing_secret, + ) + + @app.action("a") + async def listener(ack, client: AsyncWebClient): + assert client.logger == self.test_logger + assert app.logger != self.test_logger + await ack() + + request = self.build_valid_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + +block_actions_body = { + "type": "block_actions", + "user": { + "id": "W99999", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +raw_body = f"payload={quote(json.dumps(block_actions_body))}" diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py new file mode 100644 index 000000000..a99dedbfe --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -0,0 +1,599 @@ +import json +import logging +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +from tests.mock_web_api_server import ( + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncWorkflowSteps: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_app(self, callback_id: str): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.step(callback_id=callback_id, edit=edit, save=save, execute=execute) + return app + + def build_process_before_response_app(self, callback_id: str): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.step( + callback_id=callback_id, + edit=[edit_ack, edit_lazy], + save=[save_ack, save_lazy], + execute=[execute_ack, execute_lazy], + ) + return app + + @pytest.mark.asyncio + async def test_edit(self): + app = self.build_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + app = self.build_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_edit_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/views.open", 1) + + app = self.build_process_before_response_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + app = self.build_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + app = self.build_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/workflows.updateStep", 1) + + app = self.build_process_before_response_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + app = self.build_app("copy_review") + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/workflows.stepCompleted", 1) + + app = self.build_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute_process_before_response(self): + app = self.build_process_before_response_app("copy_review") + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/workflows.stepCompleted", 1) + + app = self.build_process_before_response_app("copy_review___") + response = await app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_custom_logger_propagation(self): + custom_logger = logging.getLogger(f"{__name__}-{time()}-async-logger-test") + custom_logger.setLevel(logging.INFO) + added_handler = logging.NullHandler() + custom_logger.addHandler(added_handler) + added_filter = logging.Filter() + custom_logger.addFilter(added_filter) + + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + + async def verify_logger_is_properly_passed(ack: AsyncAck, logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + await ack() + + app.step( + callback_id="copy_review", + edit=verify_logger_is_properly_passed, + save=verify_logger_is_properly_passed, + execute=verify_logger_is_properly_passed, + ) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: AsyncBoltRequest = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) + + +async def edit_ack(ack: AsyncAck): + await ack() + + +async def edit_lazy(step, configure: AsyncConfigure): + assert step is not None + await configure(blocks=[]) + + +async def save_ack(ack: AsyncAck): + await ack() + + +async def save_lazy(step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + await update( + inputs={}, + outputs=[], + ) + + +async def execute_ack(): + pass + + +async def execute_lazy(step: dict, complete: AsyncComplete, fail: AsyncFail): + assert step is not None + try: + await complete(outputs={}) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py new file mode 100644 index 000000000..1224949ae --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py @@ -0,0 +1,430 @@ +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +from tests.mock_web_api_server import ( + assert_auth_test_count_async, + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncWorkflowStepsDecorator: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + @pytest.mark.asyncio + async def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/workflows.stepCompleted", 1) + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +@copy_review_step.edit +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +@copy_review_step.execute +async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py new file mode 100644 index 000000000..53bec512d --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py @@ -0,0 +1,538 @@ +import json +import logging +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +from tests.mock_web_api_server import ( + assert_auth_test_count_async, + assert_received_request_count_async, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncWorkflowStepsDecorator: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + @pytest.mark.asyncio + async def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + await assert_received_request_count_async(self, "/workflows.stepCompleted", 1) + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(callback_id="copy_review___", edit=edit, save=save, execute=execute) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_logger_propagation(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + logger=custom_logger, + ) + app.step(logger_test_step) + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + +# +# The normal pattern tests +# + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +async def noop_middleware(next): + return await next() + + +@copy_review_step.edit(middleware=[noop_middleware]) +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save(middleware=[noop_middleware]) +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"]["value"], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + ) + await ack() + + +pseudo_database = {} + + +@copy_review_step.execute(middleware=[noop_middleware]) +async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) + + +# +# Logger propagation tests +# + +custom_logger = logging.getLogger(f"{__name__}-{time()}-async-logger-test") +custom_logger.setLevel(logging.INFO) +added_handler = logging.NullHandler() +custom_logger.addHandler(added_handler) +added_filter = logging.Filter() +custom_logger.addFilter(added_filter) + +logger_test_step = AsyncWorkflowStep.builder( + "copy_review", + base_logger=custom_logger, # to pass this logger to middleware / middleware matchers +) + + +def _verify_logger(logger: logging.Logger): + assert logger.level == custom_logger.level + assert len(logger.handlers) == len(custom_logger.handlers) + assert logger.handlers[-1] == custom_logger.handlers[-1] + assert len(logger.filters) == len(custom_logger.filters) + assert logger.filters[-1] == custom_logger.filters[-1] + + +async def logger_middleware(next, logger): + _verify_logger(logger) + await next() + + +async def logger_matcher(logger): + _verify_logger(logger) + return True + + +@logger_test_step.edit( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +async def edit_for_logger_test(ack: AsyncAck, logger: logging.Logger): + _verify_logger(logger) + await ack() + + +@logger_test_step.save( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +async def save_for_logger_test(ack: AsyncAck, logger: logging.Logger): + _verify_logger(logger) + await ack() + + +@logger_test_step.execute( + middleware=[logger_middleware], + matchers=[logger_matcher], +) +async def execute_for_logger_test(logger: logging.Logger): + _verify_logger(logger) diff --git a/tests/slack_bolt/__init__.py b/tests/slack_bolt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/app/__init__.py b/tests/slack_bolt/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/app/test_dev_server.py b/tests/slack_bolt/app/test_dev_server.py new file mode 100644 index 000000000..978cffc3f --- /dev/null +++ b/tests/slack_bolt/app/test_dev_server.py @@ -0,0 +1,34 @@ +from slack_sdk import WebClient + +from slack_bolt.app.app import SlackAppDevelopmentServer, App +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestDevServer: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + 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_instance(self): + server = SlackAppDevelopmentServer( + port=3001, + path="/slack/events", + app=App(signing_secret=self.signing_secret, client=self.web_client), + ) + assert server is not None diff --git a/tests/slack_bolt/authorization/__init__.py b/tests/slack_bolt/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/authorization/test_authorize.py b/tests/slack_bolt/authorization/test_authorize.py new file mode 100644 index 000000000..69d6fca6d --- /dev/null +++ b/tests/slack_bolt/authorization/test_authorize.py @@ -0,0 +1,488 @@ +import datetime +import logging +from logging import Logger +from typing import Optional + +import pytest +from slack_sdk import WebClient +from slack_sdk.oauth import InstallationStore +from slack_sdk.oauth.installation_store import Bot, Installation + +from slack_bolt import BoltContext +from slack_bolt.authorization.authorize import InstallationStoreAuthorize, Authorize +from slack_bolt.error import BoltError +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, + assert_auth_test_count, +) + + +class TestAuthorize: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_root_class(self): + authorize = Authorize() + with pytest.raises(NotImplementedError): + authorize( + context=BoltContext(), + enterprise_id="E111", + team_id="T111", + user_id="U111", + ) + + def test_installation_store_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = InstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + assert authorize.find_installation_available is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) + + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 2) + + def test_installation_store_cached_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + assert authorize.find_installation_available is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) + + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) # cached + + def test_installation_store_bot_only(self): + installation_store = BotOnlyMemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + bot_only=True, + ) + assert authorize.find_installation_available is True + assert authorize.bot_only is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) + + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 2) + + def test_installation_store_cached_bot_only(self): + installation_store = BotOnlyMemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + bot_only=True, + ) + assert authorize.find_installation_available is True + assert authorize.bot_only is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) + + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) # cached + + def test_installation_store(self): + installation_store = MemoryInstallationStore() + authorize = InstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + assert authorize.find_installation_available is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 2) + + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 4) + + def test_installation_store_cached(self): + installation_store = MemoryInstallationStore() + authorize = InstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + assert authorize.find_installation_available is True + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 2) + + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 2) # cached + + def test_fetch_different_user_token(self): + installation_store = ValidUserTokenInstallationStore() + authorize = InstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 2) + + def test_fetch_different_user_token_with_rotation(self): + context = BoltContext() + mock_client = WebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + ) + + authorize = InstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token == "xoxp-valid-refreshed" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 2) + + def test_remove_latest_user_token_if_it_is_not_relevant(self): + installation_store = ValidUserTokenInstallationStore() + authorize = InstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + context = BoltContext() + context["client"] = WebClient(base_url=self.mock_api_server_base_url) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) + + def test_rotate_only_bot_token(self): + context = BoltContext() + mock_client = WebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = InstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W333", + ) + + authorize = InstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + assert_auth_test_count(self, 1) + + +class LegacyMemoryInstallationStore(InstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-1", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError + + +class ValidUserTokenInstallationStore(InstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class ValidUserTokenRotationInstallationStore(InstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + def save(self, installation: Installation): + pass + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_refresh_token="xoxe-bot-valid", + bot_token_expires_in=-10, + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) diff --git a/tests/slack_bolt/context/__init__.py b/tests/slack_bolt/context/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/context/test_ack.py b/tests/slack_bolt/context/test_ack.py new file mode 100644 index 000000000..25dbb00a5 --- /dev/null +++ b/tests/slack_bolt/context/test_ack.py @@ -0,0 +1,197 @@ +from slack_sdk.models.blocks import PlainTextObject, DividerBlock +from slack_sdk.models.views import View + +from slack_bolt import Ack, BoltResponse + + +class TestAck: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_text(self): + ack = Ack() + response: BoltResponse = ack(text="foo") + assert (response.status, response.body) == (200, "foo") + + sample_attachments = [ + { + "fallback": "Plain-text summary of the attachment.", + "color": "#2eb886", + "pretext": "Optional text that appears above the attachment block", + "author_name": "Bobby Tables", + "author_link": "http://flickr.com/bobby/", + "author_icon": "http://flickr.com/icons/bobby.jpg", + "title": "Slack API Documentation", + "title_link": "https://api.slack.com/", + "text": "Optional text that appears within the attachment", + "fields": [{"title": "Priority", "value": "High", "short": False}], + "image_url": "http://my-website.com/path/to/image.jpg", + "thumb_url": "http://example.com/path/to/thumb.png", + "footer": "Slack API", + "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", + "ts": 123456789, + } + ] + + def test_attachments(self): + ack = Ack() + response: BoltResponse = ack(text="foo", attachments=self.sample_attachments) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", ' + '"attachments": [{"fallback": "Plain-text summary of the attachment.", "color": "#2eb886", "pretext": "Optional text that appears above the attachment block", "author_name": "Bobby Tables", "author_link": "http://flickr.com/bobby/", "author_icon": "http://flickr.com/icons/bobby.jpg", "title": "Slack API Documentation", "title_link": "https://api.slack.com/", "text": "Optional text that appears within the attachment", "fields": [{"title": "Priority", "value": "High", "short": false}], "image_url": "http://my-website.com/path/to/image.jpg", "thumb_url": "http://example.com/path/to/thumb.png", "footer": "Slack API", "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", "ts": 123456789}]' + "}", + ) + + def test_blocks(self): + ack = Ack() + response: BoltResponse = ack(text="foo", blocks=[{"type": "divider"}]) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "blocks": [{"type": "divider"}]}', + ) + + def test_unfurl_options(self): + ack = Ack() + response: BoltResponse = ack( + text="foo", + blocks=[{"type": "divider"}], + unfurl_links=True, + unfurl_media=True, + ) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "unfurl_links": true, "unfurl_media": true, "blocks": [{"type": "divider"}]}', + ) + + sample_options = [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] + + def test_options(self): + ack = Ack() + response: BoltResponse = ack(text="foo", options=self.sample_options) + assert response.status == 200 + assert response.body == '{"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}' + + sample_option_groups = [ + { + "label": {"type": "plain_text", "text": "Group 1"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "1-1"}, + {"text": {"type": "plain_text", "text": "Option 2"}, "value": "1-2"}, + ], + }, + { + "label": {"type": "plain_text", "text": "Group 2"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "2-1"}, + ], + }, + ] + + def test_option_groups(self): + ack = Ack() + response: BoltResponse = ack(text="foo", option_groups=self.sample_option_groups) + assert response.status == 200 + assert response.body.startswith('{"option_groups":') + + def test_response_type(self): + ack = Ack() + response: BoltResponse = ack(text="foo", response_type="in_channel") + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "response_type": "in_channel"}', + ) + + def test_dialog_errors(self): + expected_body = '{"errors": [{"name": "loc_origin", "error": "Pickup Location must be longer than 3 characters"}]}' + errors = [ + { + "name": "loc_origin", + "error": "Pickup Location must be longer than 3 characters", + } + ] + + ack = Ack() + response: BoltResponse = ack(errors=errors) + assert (response.status, response.body) == (200, expected_body) + response: BoltResponse = ack({"errors": errors}) + assert (response.status, response.body) == (200, expected_body) + + def test_view_errors(self): + ack = Ack() + response: BoltResponse = ack( + response_action="errors", + errors={ + "block_title": "Title is required", + "block_description": "Description must be longer than 10 characters", + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "errors", ' + '"errors": {' + '"block_title": "Title is required", ' + '"block_description": "Description must be longer than 10 characters"' + "}" + "}", + ) + + def test_view_update(self): + ack = Ack() + response: BoltResponse = ack( + response_action="update", + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [{"type": "divider", "block_id": "b"}], + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "update", ' + '"view": {' + '"type": "modal", ' + '"callback_id": "view-id", ' + '"title": {"type": "plain_text", "text": "My App"}, ' + '"close": {"type": "plain_text", "text": "Cancel"}, ' + '"blocks": [{"type": "divider", "block_id": "b"}]' + "}" + "}", + ) + + def test_view_update_2(self): + ack = Ack() + response: BoltResponse = ack( + response_action="update", + view=View( + type="modal", + callback_id="view-id", + title=PlainTextObject(text="My App"), + close=PlainTextObject(text="Cancel"), + blocks=[DividerBlock(block_id="b")], + ), + ) + assert (response.status, response.body) == ( + 200, + "" + '{"response_action": "update", ' + '"view": {' + '"blocks": [{"block_id": "b", "type": "divider"}], ' + '"callback_id": "view-id", ' + '"close": {"text": "Cancel", "type": "plain_text"}, ' + '"title": {"text": "My App", "type": "plain_text"}, ' + '"type": "modal"' + "}" + "}", + ) diff --git a/tests/slack_bolt/context/test_complete.py b/tests/slack_bolt/context/test_complete.py new file mode 100644 index 000000000..63a1d9f04 --- /dev/null +++ b/tests/slack_bolt/context/test_complete.py @@ -0,0 +1,41 @@ +import pytest + +from slack_sdk import WebClient +from slack_bolt.context.complete import Complete +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestComplete: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_complete(self): + complete_success = Complete(client=self.web_client, function_execution_id="fn1111") + + response = complete_success(outputs={"key": "value"}) + + assert response.status_code == 200 + + def test_complete_no_function_execution_id(self): + complete = Complete(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + complete(outputs={"key": "value"}) + + def test_has_been_called_false_initially(self): + complete = Complete(client=self.web_client, function_execution_id="fn1111") + assert complete.has_been_called() is False + + def test_has_been_called_true_after_complete(self): + complete = Complete(client=self.web_client, function_execution_id="fn1111") + complete(outputs={"key": "value"}) + assert complete.has_been_called() is True diff --git a/tests/slack_bolt/context/test_fail.py b/tests/slack_bolt/context/test_fail.py new file mode 100644 index 000000000..14348281f --- /dev/null +++ b/tests/slack_bolt/context/test_fail.py @@ -0,0 +1,41 @@ +import pytest + +from slack_sdk import WebClient +from slack_bolt.context.fail import Fail +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestFail: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_fail(self): + fail = Fail(client=self.web_client, function_execution_id="fn1111") + + response = fail(error="something went wrong") + + assert response.status_code == 200 + + def test_fail_no_function_execution_id(self): + fail = Fail(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + fail(error="there was an error") + + def test_has_been_called_false_initially(self): + fail = Fail(client=self.web_client, function_execution_id="fn1111") + assert fail.has_been_called() is False + + def test_has_been_called_true_after_fail(self): + fail = Fail(client=self.web_client, function_execution_id="fn1111") + fail(error="there was an error") + assert fail.has_been_called() is True diff --git a/tests/slack_bolt/context/test_respond.py b/tests/slack_bolt/context/test_respond.py new file mode 100644 index 000000000..88e98dec8 --- /dev/null +++ b/tests/slack_bolt/context/test_respond.py @@ -0,0 +1,41 @@ +from slack_bolt.context.respond import Respond +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestRespond: + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_respond(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond(text="Hi there!") + assert response.status_code == 200 + + def test_respond2(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond({"text": "Hi there!"}) + assert response.status_code == 200 + + def test_unfurl_options(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond(text="Hi there!", unfurl_media=True, unfurl_links=True) + assert response.status_code == 200 + + def test_metadata(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond( + text="Hi there!", + response_type="in_channel", + metadata={"event_type": "foo", "event_payload": {"foo": "bar"}}, + ) + assert response.status_code == 200 diff --git a/tests/slack_bolt/context/test_respond_internals.py b/tests/slack_bolt/context/test_respond_internals.py new file mode 100644 index 000000000..7d497e205 --- /dev/null +++ b/tests/slack_bolt/context/test_respond_internals.py @@ -0,0 +1,59 @@ +from slack_sdk.models.blocks import DividerBlock + +from slack_bolt.context.respond.internals import _build_message + + +class TestRespondInternals: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_build_message_empty(self): + message = _build_message() + assert message is not None + + def test_build_message_text(self): + message = _build_message(text="Hello!") + assert message is not None + + def test_build_message_blocks(self): + message = _build_message(blocks=[{"type": "divider"}]) + assert message is not None + + def test_build_message_blocks2(self): + message = _build_message(blocks=[DividerBlock(block_id="foo")]) + assert message is not None + assert isinstance(message["blocks"][0], dict) + assert message["blocks"][0]["block_id"] == "foo" + + def test_build_message_attachments(self): + message = _build_message(attachments=[{}]) + assert message is not None + + def test_build_message_response_type(self): + message = _build_message(response_type="in_channel") + assert message is not None + + def test_build_message_replace_original(self): + message = _build_message(replace_original=True) + assert message is not None + + def test_build_message_delete_original(self): + message = _build_message(delete_original=True) + assert message is not None + + def test_build_message_unfurl_options(self): + message = _build_message(text="Hi there!", unfurl_links=True, unfurl_media=True) + assert message is not None + assert message.get("unfurl_links") is True + assert message.get("unfurl_media") is True + + def test_metadata(self): + message = _build_message( + text="Hi there!", response_type="in_channel", metadata={"event_type": "foo", "event_payload": {"foo": "bar"}} + ) + assert message is not None + assert message.get("metadata").get("event_type") == "foo" + assert message.get("metadata").get("event_payload") == {"foo": "bar"} diff --git a/tests/slack_bolt/context/test_say.py b/tests/slack_bolt/context/test_say.py new file mode 100644 index 000000000..6ca1fc96a --- /dev/null +++ b/tests/slack_bolt/context/test_say.py @@ -0,0 +1,65 @@ +import pytest +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import Say +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSay: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_say(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(text="Hi there!") + assert response.status_code == 200 + + def test_say_markdown_text(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(markdown_text="**Greetings!**") + assert response.status_code == 200 + + def test_say_unfurl_options(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(text="Hi there!", unfurl_media=True, unfurl_links=True) + assert response.status_code == 200 + + def test_say_reply_in_thread(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(text="Hi there!", thread_ts="111.222", reply_broadcast=True) + assert response.status_code == 200 + + def test_say_dict(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say({"text": "Hi!"}) + assert response.status_code == 200 + + def test_say_dict_channel(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say({"text": "Hi!", "channel": "C111"}) + assert response.status_code == 200 + + def test_say_invalid(self): + say = Say(client=self.web_client, channel="C111") + with pytest.raises(ValueError): + say([]) + + def test_say_shared_dict_as_arg(self): + # this shared dict object must not be modified by say method + shared_template_dict = {"text": "Hi there!"} + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(shared_template_dict) + assert response.status_code == 200 + assert shared_template_dict.get("channel") is None + + say = Say(client=self.web_client, channel="C222") + response: SlackResponse = say(shared_template_dict) + assert response.status_code == 200 + assert shared_template_dict.get("channel") is None 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 new file mode 100644 index 000000000..bb5807e96 --- /dev/null +++ b/tests/slack_bolt/context/test_set_status.py @@ -0,0 +1,47 @@ +import pytest +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt.context.set_status import SetStatus +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSetStatus: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_set_status(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_status("Thinking...") + assert response.status_code == 200 + + def test_set_status_loading_messages(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_status( + status="Thinking...", + loading_messages=[ + "Sitting...", + "Waiting...", + ], + ) + assert response.status_code == 200 + + def test_set_status_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): + set_status() diff --git a/tests/slack_bolt/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py new file mode 100644 index 000000000..a743d5656 --- /dev/null +++ b/tests/slack_bolt/context/test_set_suggested_prompts.py @@ -0,0 +1,80 @@ +from unittest.mock import MagicMock, patch + +import pytest +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSetSuggestedPrompts: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_set_suggested_prompts(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_suggested_prompts(prompts=["One", "Two"]) + assert response.status_code == 200 + + def test_set_suggested_prompts_objects(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_suggested_prompts( + prompts=[ + "One", + {"title": "Two", "message": "What's before addition?"}, + ], + ) + assert response.status_code == 200 + + def test_set_suggested_prompts_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): + set_suggested_prompts() diff --git a/tests/slack_bolt/error/__init__.py b/tests/slack_bolt/error/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/error/test_errors.py b/tests/slack_bolt/error/test_errors.py new file mode 100644 index 000000000..42a143d7c --- /dev/null +++ b/tests/slack_bolt/error/test_errors.py @@ -0,0 +1,19 @@ +from slack_bolt import BoltRequest +from slack_bolt.error import BoltUnhandledRequestError + + +class TestErrors: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_say(self): + request = BoltRequest(body="foo=bar") + exception = BoltUnhandledRequestError( + request=request, + current_response={}, + last_global_middleware_name="last_middleware", + ) + assert str(exception) == "unhandled request error" diff --git a/tests/slack_bolt/kwargs_injection/__init__.py b/tests/slack_bolt/kwargs_injection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/kwargs_injection/test_args.py b/tests/slack_bolt/kwargs_injection/test_args.py new file mode 100644 index 000000000..530a5a703 --- /dev/null +++ b/tests/slack_bolt/kwargs_injection/test_args.py @@ -0,0 +1,60 @@ +import logging + +from slack_bolt import Args, BoltRequest, BoltResponse +from slack_bolt.kwargs_injection import build_required_kwargs + + +class TestArgs: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def next(self): + pass + + def test_build(self): + required_args = [ + "logger", + "client", + "req", + "resp", + "context", + "body", + "payload", + "ack", + "say", + "respond", + "complete", + "fail", + "next", + ] + arg_params: dict = build_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=BoltRequest(body="", headers={}), + response=BoltResponse(status=200), + next_func=next, + ) + args = Args(**arg_params) + assert args.logger is not None + assert args.request is not None + assert args.response is not None + assert args.client is not None + + def test_all_values_from_context(self): + req = BoltRequest(body="", headers={}) + req.context["foo"] = "FOO" + req.context["bar"] = 123 + required_args = ["foo", "bar", "ack"] + arg_params: dict = build_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=req, + response=BoltResponse(status=200), + next_func=next, + ) + assert arg_params["foo"] == "FOO" + assert arg_params["bar"] == 123 + assert arg_params["ack"] is not None diff --git a/tests/slack_bolt/listener/__init__.py b/tests/slack_bolt/listener/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/listener/test_listener_completion_handler.py b/tests/slack_bolt/listener/test_listener_completion_handler.py new file mode 100644 index 000000000..b55a6eb27 --- /dev/null +++ b/tests/slack_bolt/listener/test_listener_completion_handler.py @@ -0,0 +1,27 @@ +import logging + +from slack_bolt import BoltRequest +from slack_bolt.listener.listener_completion_handler import ( + CustomListenerCompletionHandler, +) + + +class TestListenerCompletionHandler: + def test_handler(self): + result = {"called": False} + + def f(): + result["called"] = True + + handler = CustomListenerCompletionHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = BoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt/listener/test_listener_start_handler.py b/tests/slack_bolt/listener/test_listener_start_handler.py new file mode 100644 index 000000000..73a4d9c10 --- /dev/null +++ b/tests/slack_bolt/listener/test_listener_start_handler.py @@ -0,0 +1,27 @@ +import logging + +from slack_bolt import BoltRequest +from slack_bolt.listener.listener_start_handler import ( + CustomListenerStartHandler, +) + + +class TestListenerStartHandler: + def test_handler(self): + result = {"called": False} + + def f(): + result["called"] = True + + handler = CustomListenerStartHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = BoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt/listener_matcher/__init__.py b/tests/slack_bolt/listener_matcher/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/listener_matcher/test_builtins.py b/tests/slack_bolt/listener_matcher/test_builtins.py new file mode 100644 index 000000000..4bb353913 --- /dev/null +++ b/tests/slack_bolt/listener_matcher/test_builtins.py @@ -0,0 +1,235 @@ +import json +import re +from urllib.parse import quote + +from slack_bolt import BoltRequest, BoltResponse +from slack_bolt.listener_matcher.builtins import ( + block_action, + action, + workflow_step_execute, + event, + shortcut, +) + + +class TestBuiltins: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_block_action(self): + body = { + "type": "block_actions", + "actions": [ + { + "type": "button", + "action_id": "valid_action_id", + "block_id": "b", + "action_ts": "111.222", + "value": "v", + } + ], + } + raw_body = f"payload={quote(json.dumps(body))}" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + req = BoltRequest(body=raw_body, headers=headers) + resp = BoltResponse(status=404) + + assert block_action("valid_action_id").matches(req, resp) is True + assert block_action("invalid_action_id").matches(req, resp) is False + assert block_action(re.compile("valid_.+")).matches(req, resp) is True + assert block_action(re.compile("invalid_.+")).matches(req, resp) is False + + assert action("valid_action_id").matches(req, resp) is True + assert action("invalid_action_id").matches(req, resp) is False + assert action(re.compile("valid_.+")).matches(req, resp) is True + assert action(re.compile("invalid_.+")).matches(req, resp) is False + + assert action({"action_id": "valid_action_id"}).matches(req, resp) is True + assert action({"action_id": "invalid_action_id"}).matches(req, resp) is False + assert action({"action_id": re.compile("valid_.+")}).matches(req, resp) is True + assert action({"action_id": re.compile("invalid_.+")}).matches(req, resp) is False + + # block_id + action_id + assert action({"action_id": "valid_action_id", "block_id": "b"}).matches(req, resp) is True + assert action({"action_id": "invalid_action_id", "block_id": "b"}).matches(req, resp) is False + assert action({"action_id": re.compile("valid_.+"), "block_id": "b"}).matches(req, resp) is True + assert action({"action_id": re.compile("invalid_.+"), "block_id": "b"}).matches(req, resp) is False + + assert action({"action_id": "valid_action_id", "block_id": "bbb"}).matches(req, resp) is False + assert action({"action_id": "invalid_action_id", "block_id": "bbb"}).matches(req, resp) is False + assert action({"action_id": re.compile("valid_.+"), "block_id": "bbb"}).matches(req, resp) is False + assert action({"action_id": re.compile("invalid_.+"), "block_id": "bbb"}).matches(req, resp) is False + + # with type + assert action({"action_id": "valid_action_id", "type": "block_actions"}).matches(req, resp) is True + assert action({"callback_id": "valid_action_id", "type": "interactive_message"}).matches(req, resp) is False + assert action({"callback_id": "valid_action_id", "type": "workflow_step_edit"}).matches(req, resp) is False + + def test_workflow_step_execute(self): + payload = { + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": {"taskName": {"value": "a"}}, + "outputs": [{"name": "taskName", "type": "text", "label": "Task Name"}], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, + } + + request = BoltRequest(body=f"payload={quote(json.dumps(payload))}") + + m = workflow_step_execute("copy_review") + assert m.matches(request, None) == True + + m = workflow_step_execute("copy_review_2") + assert m.matches(request, None) == False + + m = workflow_step_execute(re.compile("copy_.+")) + assert m.matches(request, None) == True + + def test_events(self): + request = BoltRequest(body=json.dumps(event_payload)) + + m = event("app_mention") + assert m.matches(request, None) + m = event({"type": "app_mention"}) + assert m.matches(request, None) + m = event("message") + assert not m.matches(request, None) + m = event({"type": "message"}) + assert not m.matches(request, None) + + request = BoltRequest(body=f"payload={quote(json.dumps(shortcut_payload))}") + + m = event("app_mention") + assert not m.matches(request, None) + m = event({"type": "app_mention"}) + assert not m.matches(request, None) + + def test_global_shortcuts(self): + request = BoltRequest(body=f"payload={quote(json.dumps(shortcut_payload))}") + + m = shortcut("test-shortcut") + assert m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "shortcut"}) + assert m.matches(request, None) + + m = shortcut("test-shortcut!!!") + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "message_action"}) + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut!!!", "type": "shortcut"}) + assert not m.matches(request, None) + + def test_message_shortcuts(self): + request = BoltRequest(body=f"payload={quote(json.dumps(message_shortcut_payload))}") + + m = shortcut("test-shortcut") + assert m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "message_action"}) + assert m.matches(request, None) + + m = shortcut("test-shortcut!!!") + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "shortcut"}) + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut!!!", "type": "message_action"}) + assert not m.matches(request, None) + + +event_payload = { + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], +} + +shortcut_payload = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", +} + + +message_shortcut_payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", +} diff --git a/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py new file mode 100644 index 000000000..5a680f137 --- /dev/null +++ b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py @@ -0,0 +1,33 @@ +from slack_bolt import BoltRequest, BoltResponse, CustomListenerMatcher +from slack_bolt.listener_matcher import ListenerMatcher + + +def func(body, request, response, dummy): + assert body is not None + assert request is not None + assert response is not None + assert dummy is None + return body["result"] + + +class TestCustomListenerMatcher: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_instantiation(self): + matcher: ListenerMatcher = CustomListenerMatcher( + app_name="foo", + func=func, + ) + resp = BoltResponse(status=201) + + req = BoltRequest(body='payload={"result":true}') + result = matcher.matches(req, resp) + assert result is True + + req = BoltRequest(body='payload={"result":false}') + result = matcher.matches(req, resp) + assert result is False diff --git a/tests/slack_bolt/logger/__init__.py b/tests/slack_bolt/logger/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/logger/test_unmatched_suggestions.py b/tests/slack_bolt/logger/test_unmatched_suggestions.py new file mode 100644 index 000000000..b470fa061 --- /dev/null +++ b/tests/slack_bolt/logger/test_unmatched_suggestions.py @@ -0,0 +1,885 @@ +from slack_bolt.request import BoltRequest +from slack_bolt.logger.messages import warning_unhandled_request + + +class TestUnmatchedPatternSuggestions: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_unknown_patterns(self): + req: BoltRequest = BoltRequest(body={"type": "foo"}, mode="socket_mode") + message = warning_unhandled_request(req) + assert f"Unhandled request ({req.body})" == message + + def test_block_actions(self): + req: BoltRequest = BoltRequest(body=block_actions, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "block_actions", + "block_id": "b", + "action_id": "action-id-value", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.action("action-id-value") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) +""" == message + + def test_attachment_actions(self): + req: BoltRequest = BoltRequest(body=attachment_actions, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "interactive_message", + "callback_id": "pick_channel_for_fun", + "actions": [ + { + "name": "channel_list", + "type": "select", + "selected_options": [{"value": "C111"}], + } + ], + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.action("pick_channel_for_fun") +def handle_some_action(ack, body, logger): + ack() + logger.info(body) +""" == message + + def test_app_mention_event(self): + req: BoltRequest = BoltRequest(body=app_mention_event, mode="socket_mode") + filtered_body = { + "type": "event_callback", + "event": {"type": "app_mention"}, + } + message = warning_unhandled_request(req) + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.event("app_mention") +def handle_app_mention_events(body, logger): + logger.info(body) +""" == message + + def test_function_event(self): + req: BoltRequest = BoltRequest(body=function_event, mode="socket_mode") + filtered_body = { + "type": "event_callback", + "event": {"type": "function_executed"}, + } + message = warning_unhandled_request(req) + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.function("reverse") +def handle_some_function(ack, body, complete, fail, logger): + ack() + logger.info(body) + try: + # TODO: do something here + outputs = {{}} + complete(outputs=outputs) + except Exception as e: + error = f"Failed to handle a function request (error: {{e}})" + fail(error=error) +""" == message + + def test_commands(self): + req: BoltRequest = BoltRequest(body=slash_command, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": None, + "command": "/start-conv", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.command("/start-conv") +def handle_some_command(ack, body, logger): + ack() + logger.info(body) +""" == message + + def test_shortcut(self): + req: BoltRequest = BoltRequest(body=global_shortcut, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "shortcut", + "callback_id": "test-shortcut", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.shortcut("test-shortcut") +def handle_shortcuts(ack, body, logger): + ack() + logger.info(body) +""" == message + + req: BoltRequest = BoltRequest(body=message_shortcut, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "message_action", + "callback_id": "test-shortcut", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.shortcut("test-shortcut") +def handle_shortcuts(ack, body, logger): + ack() + logger.info(body) +""" == message + + def test_view(self): + req: BoltRequest = BoltRequest(body=view_submission, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_submission", + "view": {"type": "modal", "callback_id": "view-id"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.view("view-id") +def handle_view_submission_events(ack, body, logger): + ack() + logger.info(body) +""" == message + + req: BoltRequest = BoltRequest(body=view_closed, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_closed", + "view": {"type": "modal", "callback_id": "view-id"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.view_closed("view-id") +def handle_view_closed_events(ack, body, logger): + ack() + logger.info(body) +""" == message + + def test_block_suggestion(self): + req: BoltRequest = BoltRequest(body=block_suggestion, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "block_suggestion", + "view": {"type": "modal", "callback_id": "view-id"}, + "block_id": "block-id", + "action_id": "the-id", + "value": "search word", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.options("the-id") +def handle_some_options(ack): + ack(options=[ ... ]) +""" == message + + def test_dialog_suggestion(self): + req: BoltRequest = BoltRequest(body=dialog_suggestion, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "dialog_suggestion", + "callback_id": "the-id", + "value": "search keyword", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.options({{"type": "dialog_suggestion", "callback_id": "the-id"}}) +def handle_some_options(ack): + ack(options=[ ... ]) +""" == message + + def test_step(self): + req: BoltRequest = BoltRequest(body=step_edit_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "workflow_step_edit", + "callback_id": "copy_review", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" == message + req: BoltRequest = BoltRequest(body=step_save_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_submission", + "view": {"type": "workflow_step", "callback_id": "copy_review"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" == message + req: BoltRequest = BoltRequest(body=step_execute_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "event_callback", + "event": {"type": "workflow_step_execute"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step import WorkflowStep +ws = WorkflowStep( + callback_id="your-callback-id", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" == message + + +block_actions = { + "type": "block_actions", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "action-id-value", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +attachment_actions = { + "type": "interactive_message", + "actions": [ + { + "name": "channel_list", + "type": "select", + "selected_options": [{"value": "C111"}], + } + ], + "callback_id": "pick_channel_for_fun", + "team": {"id": "T111", "domain": "hooli-hq"}, + "channel": {"id": "C222", "name": "triage-random"}, + "user": {"id": "U111", "name": "gbelson"}, + "action_ts": "1520966872.245369", + "message_ts": "1520965348.000538", + "attachment_id": "1", + "token": "verification_token", + "is_app_unfurl": True, + "original_message": { + "text": "", + "username": "Belson Bot", + "bot_id": "B111", + "attachments": [ + { + "callback_id": "pick_channel_for_fun", + "text": "Choose a channel", + "id": 1, + "color": "2b72cb", + "actions": [ + { + "id": "1", + "name": "channel_list", + "text": "Public channels", + "type": "select", + "data_source": "channels", + } + ], + "fallback": "Choose a channel", + } + ], + "type": "message", + "subtype": "bot_message", + "ts": "1520965348.000538", + }, + "response_url": "https://hooks.slack.com/actions/T111/111/xxxx", + "trigger_id": "111.222.valid", +} + + +app_mention_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, +} + +function_event = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + +slash_command = { + "token": "fixed-verification-token", + "team_id": "T111", + "team_domain": "maria", + "channel_id": "C111", + "channel_name": "general", + "user_id": "U111", + "user_name": "rainer", + "command": "/start-conv", + "text": "title", + "response_url": "https://xxx.slack.com/commands/T111/xxx/zzz", + "trigger_id": "111.222.xxx", +} + +step_edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +step_save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +step_execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + +global_shortcut = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", +} + +message_shortcut = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", +} + +view_submission = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": {"values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}}}, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +view_closed = { + "type": "view_closed", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": {"values": {}}, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +block_suggestion = { + "type": "block_suggestion", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "container": {"type": "view", "view_id": "V111"}, + "api_app_id": "A111", + "token": "verification_token", + "action_id": "the-id", + "block_id": "block-id", + "value": "search word", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "5ar+", + "label": {"type": "plain_text", "text": "Label"}, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "i5IpR"}, + }, + { + "type": "input", + "block_id": "es_b", + "label": {"type": "plain_text", "text": "Search"}, + "optional": False, + "element": { + "type": "external_select", + "action_id": "es_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + }, + }, + { + "type": "input", + "block_id": "mes_b", + "label": {"type": "plain_text", "text": "Search (multi)"}, + "optional": False, + "element": { + "type": "multi_external_select", + "action_id": "mes_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "view-id", + "state": {"values": {}}, + "hash": "111.xxx", + "title": {"type": "plain_text", "text": "My App"}, + "clear_on_close": False, + "notify_on_close": False, + "close": {"type": "plain_text", "text": "Cancel"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, +} + +dialog_suggestion = { + "type": "dialog_suggestion", + "token": "verification_token", + "action_ts": "1596603332.676855", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": {"id": "W111", "name": "primary-owner", "team_id": "T111"}, + "channel": {"id": "C111", "name": "test-channel"}, + "name": "types", + "value": "search keyword", + "callback_id": "the-id", + "state": "Limo", +} diff --git a/tests/slack_bolt/middleware/__init__.py b/tests/slack_bolt/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/__init__.py new file mode 100644 index 000000000..e69de29bb 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/authorization/__init__.py b/tests/slack_bolt/middleware/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py new file mode 100644 index 000000000..7e3a0c11d --- /dev/null +++ b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py @@ -0,0 +1,81 @@ +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt.middleware import SingleTeamAuthorization +from slack_bolt.middleware.authorization.internals import _build_user_facing_authorize_error_message +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +def next(): + return BoltResponse(status=200) + + +class TestSingleTeamAuthorization: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_success_pattern(self): + authorization = SingleTeamAuthorization(auth_test_result={}) + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = WebClient(base_url=self.mock_api_server_base_url, token="xoxb-valid") + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + + def test_success_pattern_with_bot_scopes(self): + client = WebClient(base_url=self.mock_api_server_base_url, token="xoxb-valid") + auth_test_result: SlackResponse = SlackResponse( + client=client, + http_verb="POST", + api_url="https://slack.com/api/auth.test", + req_args={}, + data={}, + headers={"x-oauth-scopes": "chat:write,commands"}, + status_code=200, + ) + authorization = SingleTeamAuthorization(auth_test_result=auth_test_result) + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = client + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + assert req.context.authorize_result.bot_scopes == ["chat:write", "commands"] + assert req.context.authorize_result.user_scopes is None + + def test_failure_pattern(self): + authorization = SingleTeamAuthorization(auth_test_result={}) + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = WebClient(base_url=self.mock_api_server_base_url, token="dummy") + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == _build_user_facing_authorize_error_message() + + def test_failure_pattern_custom_message(self): + authorization = SingleTeamAuthorization(auth_test_result={}, user_facing_authorize_error_message="foo") + req = BoltRequest(body="payload={}", headers={}) + req.context["client"] = WebClient(base_url=self.mock_api_server_base_url, token="dummy") + resp = BoltResponse(status=404) + + resp = authorization.process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "foo" diff --git a/tests/slack_bolt/middleware/request_verification/__init__.py b/tests/slack_bolt/middleware/request_verification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/middleware/request_verification/test_request_verification.py b/tests/slack_bolt/middleware/request_verification/test_request_verification.py new file mode 100644 index 000000000..53af43bf3 --- /dev/null +++ b/tests/slack_bolt/middleware/request_verification/test_request_verification.py @@ -0,0 +1,81 @@ +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier + +from slack_bolt.middleware import RequestVerification +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def next(): + return BoltResponse(status=200, body="next") + + +class TestRequestVerification: + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_valid(self): + middleware = RequestVerification(signing_secret=self.signing_secret) + timestamp = str(int(time())) + raw_body = "payload={}" + req = BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + resp = BoltResponse(status=404, body="default") + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 200 + assert resp.body == "next" + + def test_invalid(self): + middleware = RequestVerification(signing_secret=self.signing_secret) + req = BoltRequest(body="payload={}", headers={}) + resp = BoltResponse(status=404) + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 401 + assert resp.body == """{"error": "invalid request"}""" + + 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/oauth/__init__.py b/tests/slack_bolt/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/oauth/test_internals.py b/tests/slack_bolt/oauth/test_internals.py new file mode 100644 index 000000000..88a62e17d --- /dev/null +++ b/tests/slack_bolt/oauth/test_internals.py @@ -0,0 +1,48 @@ +from slack_bolt.oauth.internals import build_detailed_error, _build_default_install_page_html + + +class TestOAuthInternals: + def test_build_detailed_error_invalid_browser(self): + result = build_detailed_error("invalid_browser") + assert result.startswith("invalid_browser: This can occur due to page reload, ") + + def test_build_detailed_error_invalid_state(self): + result = build_detailed_error("invalid_state") + assert result.startswith("invalid_state: The state parameter is no longer valid.") + + def test_build_detailed_error_missing_code(self): + result = build_detailed_error("missing_code") + assert result.startswith("missing_code: The code parameter is missing in this redirection.") + + def test_build_detailed_error_storage_error(self): + result = build_detailed_error("storage_error") + assert result.startswith("storage_error: The app's server encountered an issue. Contact the app developer.") + + def test_build_detailed_error_others(self): + result = build_detailed_error("access_denied") + assert result.startswith( + "access_denied: This error code is returned from Slack. Refer to the documents for details." + ) + + def test_build_detailed_error_others_with_tags(self): + result = build_detailed_error("test") + assert result.startswith( + "<b>test</b>: This error code is returned from Slack. Refer to the documents for details." + ) + + def test_build_default_install_page_html(self): + test_patterns = [ + { + "input": "https://slack.com/oauth/v2/authorize?state=random&client_id=111.222&scope=commands", + "expected": "https://slack.com/oauth/v2/authorize?state=random&client_id=111.222&scope=commands", + }, + { + "input": "test", + "expected": "<b>test</b>", + }, + ] + for pattern in test_patterns: + url = pattern["input"] + result = _build_default_install_page_html(url) + assert url not in result + assert pattern["expected"] in result diff --git a/tests/slack_bolt/oauth/test_oauth_flow.py b/tests/slack_bolt/oauth/test_oauth_flow.py new file mode 100644 index 000000000..77258685d --- /dev/null +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -0,0 +1,289 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk import WebClient +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import ( + OAuthStateStore, + FileOAuthStateStore, +) +from slack_sdk.signature import SignatureVerifier + +from slack_bolt import BoltRequest, BoltResponse, App +from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, + assert_auth_test_count, +) + + +class TestOAuthFlow: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_instantiation(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None + + def test_handle_installation_default(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert resp.headers.get("set-cookie") is not None + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + + # https://github.com/slackapi/bolt-python/issues/183 + # For direct install URL support + def test_handle_installation_no_rendering(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert resp.headers.get("set-cookie") is not None + + def test_handle_installation_team_param(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = BoltRequest(body="", query={"team": "T12345"}) + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert "&team=T12345" in location_header + assert resp.headers.get("set-cookie") is not None + + def test_handle_installation_no_state_validation(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_validation_enabled=False, # disabled + state_store=None, + ) + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 302 + assert resp.headers.get("set-cookie") is None + + def test_scopes_as_str(self): + settings = OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + user_scopes="search:read", + ) + assert settings.scopes == ["chat:write", "commands"] + assert settings.user_scopes == ["search:read"] + + def test_handle_callback(self): + oauth_flow = OAuthFlow( + client=WebClient(base_url=self.mock_api_server_base_url), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + app = App(signing_secret="signing_secret", oauth_flow=oauth_flow) + global_shortcut_body = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + body = f"payload={quote(json.dumps(global_shortcut_body))}" + timestamp = str(int(time())) + signature_verifier = SignatureVerifier("signing_secret") + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [signature_verifier.generate_signature(body=body, timestamp=timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = BoltRequest(body=body, headers=headers) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + + def test_handle_callback_invalid_state(self): + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 400 + + def test_handle_callback_already_expired_state(self): + class MyOAuthStateStore(OAuthStateStore): + def issue(self, *args, **kwargs) -> str: + return "expired_one" + + def consume(self, state: str) -> bool: + return False + + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=MyOAuthStateStore(), + ) + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 401 + + def test_handle_callback_no_state_validation(self): + oauth_flow = OAuthFlow( + client=WebClient(base_url=self.mock_api_server_base_url), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_validation_enabled=False, # disabled + state_store=None, + ), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + + def test_handle_callback_using_options(self): + def success(args: SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + def failure(args: FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = OAuthFlow( + client=WebClient(base_url=self.mock_api_server_base_url), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + callback_options=CallbackOptions(success=success, failure=failure), + ), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" diff --git a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py new file mode 100644 index 000000000..26c54df15 --- /dev/null +++ b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py @@ -0,0 +1,119 @@ +from slack_sdk import WebClient + +from slack_bolt import BoltRequest, BoltResponse +from slack_bolt.oauth import OAuthFlow +from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) + + +class TestOAuthFlowSQLite3: + mock_api_server_base_url = "http://localhost:8888" + + def setup_method(self): + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_instantiation(self): + oauth_flow = OAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None + + def test_handle_installation(self): + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + req = BoltRequest(body="") + resp = oauth_flow.handle_installation(req) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + + def test_handle_callback(self): + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + def test_handle_callback_invalid_state(self): + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 400 + + def test_handle_callback_using_options(self): + def success(args: SuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + def failure(args: FailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = OAuthFlow.sqlite3( + client=WebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + callback_options=CallbackOptions(success=success, failure=failure), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" diff --git a/tests/slack_bolt/request/__init__.py b/tests/slack_bolt/request/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py new file mode 100644 index 000000000..31ac35bdd --- /dev/null +++ b/tests/slack_bolt/request/test_internals.py @@ -0,0 +1,1277 @@ +import pytest + +from slack_bolt.request.internals import ( + extract_channel_id, + extract_function_bot_access_token, + extract_function_inputs, + extract_user_id, + extract_team_id, + extract_enterprise_id, + parse_query, + extract_is_enterprise_install, + extract_actor_enterprise_id, + extract_actor_team_id, + extract_actor_user_id, + extract_function_execution_id, + extract_thread_ts, +) + + +class TestRequestInternals: + def setup_method(self): + pass + + def teardown_method(self): + pass + + # based on https://github.com/slackapi/bolt-js/blob/f8c25ffb5cd91827510bbc689e97556d2d5ad017/src/App.spec.ts#L1123 + requests = [ + { + "event": {"channel": "C111", "user": "U111"}, + "team_id": "T111", + "enterprise_id": "E111", + }, + { + "event": {"item": {"channel": "C111"}, "user": "U111", "item_user": "U222"}, + "team_id": "T111", + "enterprise_id": "E111", + }, + { + "command": "/hello", + "channel_id": "C111", + "team_id": "T111", + "enterprise_id": "E111", + "user_id": "U111", + }, + { + "actions": [{}], + "channel": {"id": "C111"}, + "user": {"id": "U111"}, + "team": {"id": "T111", "enterprise_id": "E111"}, + }, + { + "type": "dialog_submission", + "channel": {"id": "C111"}, + "user": {"id": "U111"}, + "team": {"id": "T111", "enterprise_id": "E111"}, + }, + ] + + enterprise_no_channel_requests = [ + { + "type": "shortcut", + "token": "xxx", + "action_ts": "1606983924.521157", + "team": {"id": "T111", "domain": "ddd"}, + "user": {"id": "U111", "username": "use", "team_id": "T111"}, + "is_enterprise_install": False, + "enterprise": {"id": "E111", "domain": "eee"}, + "callback_id": "run-socket-mode", + "trigger_id": "111.222.xxx", + }, + ] + + no_enterprise_no_channel_requests = [ + { + "type": "shortcut", + "token": "xxx", + "action_ts": "1606983924.521157", + "team": {"id": "T111", "domain": "ddd"}, + "user": {"id": "U111", "username": "use", "team_id": "T111"}, + "is_enterprise_install": False, + # This may be "null" in Socket Mode + "enterprise": None, + "callback_id": "run-socket-mode", + "trigger_id": "111.222.xxx", + }, + ] + + function_event_requests = [ + { + "type": "event_callback", + "token": "xxx", + "enterprise_id": "E111", + "team_id": "T111", + "event": {"function_execution_id": "Fx111", "bot_access_token": "xwfp-xxx", "inputs": {"customer_id": "Ux111"}}, + }, + { + "type": "block_actions", + "enterprise_id": "E111", + "team_id": "T111", + "bot_access_token": "xwfp-xxx", + "function_data": {"execution_id": "Fx111", "inputs": {"customer_id": "Ux111"}}, + "interactivity": {"interactivity_pointer": "111.222.xxx"}, + }, + { + "type": "view_submission", + "enterprise_id": "E111", + "team_id": "T111", + "bot_access_token": "xwfp-xxx", + "function_data": {"execution_id": "Fx111", "inputs": {"customer_id": "Ux111"}}, + "interactivity": {"interactivity_pointer": "111.222.xxx"}, + }, + ] + + thread_ts_event_requests = [ + { + "event": { + "type": "app_mention", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "subtype": "bot_message", + "channel": "C111", + "bot_id": "B111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "subtype": "file_share", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + { + "event": { + "type": "message", + "subtype": "thread_broadcast", + "channel": "C111", + "user": "U111", + "ts": "123.420", + "thread_ts": "123.456", + "root": {"thread_ts": "123.420"}, + }, + }, + { + "event": { + "type": "link_shared", + "channel": "C111", + "user": "U111", + "thread_ts": "123.456", + "links": [{"url": "https://example.com"}], + }, + }, + { + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C111", + "message": { + "type": "message", + "user": "U111", + "text": "edited", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + }, + { + "event": { + "type": "message", + "subtype": "message_changed", + "channel": "C111", + "message": { + "type": "message", + "user": "U111", + "text": "edited", + "ts": "123.420", + "thread_ts": "123.456", + }, + "previous_message": { + "type": "message", + "user": "U111", + "text": "deleted", + "ts": "123.420", + "thread_ts": "123.420", + }, + }, + }, + { + "event": { + "type": "message", + "subtype": "message_deleted", + "channel": "C111", + "previous_message": { + "type": "message", + "user": "U111", + "text": "deleted", + "ts": "123.420", + "thread_ts": "123.456", + }, + }, + }, + { + "event": { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T123ABC456", + "enterprise_id": "E123ABC456", + }, + "channel_id": "D123ABC456", + "thread_ts": "123.456", + }, + "event_ts": "1715873754.429808", + }, + }, + { + "event": { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "U123ABC456", + "context": { + "channel_id": "C123ABC456", + "team_id": "T123ABC456", + "enterprise_id": "E123ABC456", + }, + "channel_id": "D123ABC456", + "thread_ts": "123.456", + }, + "event_ts": "17298244.022142", + }, + }, + { + "event": { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "Chats from 2024-09-28", + "subtype": "assistant_app_thread", + "user": "U123456ABCD", + "type": "message", + "team": "T123456ABCD", + "thread_ts": "123.456", + "reply_count": 1, + "ts": "123.420", + }, + "channel": "D987654ABCD", + "hidden": True, + "ts": "123.420", + "event_ts": "123.420", + "channel_type": "im", + }, + }, + ] + + no_thread_ts_requests = [ + { + "event": { + "type": "reaction_added", + "user": "U111", + "reaction": "thumbsup", + "item": {"type": "message", "channel": "C111", "ts": "123.420"}, + }, + }, + { + "event": { + "type": "channel_created", + "channel": {"id": "C222", "name": "test", "created": 1678455198}, + }, + }, + { + "event": { + "type": "message", + "channel": "C111", + "user": "U111", + "text": "hello", + "ts": "123.420", + }, + }, + {}, + ] + + slack_connect_authorizations = [ + { + "enterprise_id": "INSTALLED_ENTERPRISE_ID", + "team_id": "INSTALLED_TEAM_ID", + "user_id": "INSTALLED_BOT_USER_ID", + "is_bot": True, + "is_enterprise_install": False, + } + ] + slack_connect_events_api_no_actor_team_requests = [ + { + "team_id": "INSTALLED_TEAM_ID", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@INSTALLED_BOT_USER_ID> hey", + "user": "USER_ID_ACTOR", + "ts": "1678451405.023359", + "team": "INSTALLED_TEAM_ID", + "user_team": "ENTERPRISE_ID_ACTOR", + "source_team": "ENTERPRISE_ID_ACTOR", + "user_profile": {"team": "ENTERPRISE_ID_ACTOR"}, + "channel": "C111", + "event_ts": "1678451405.023359", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + ] + slack_connect_events_api_no_actor_enterprise_team_requests = [ + { + "team_id": "INSTALLED_TEAM_ID", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "USER_ID_ACTOR", + "reaction": "eyes", + "item": {"type": "message", "channel": "C111", "ts": "1678453386.979699"}, + "event_ts": "1678456876.000900", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "enterprise_id": "INSTALLED_ENTERPRISE_ID", + "team_id": "INSTALLED_TEAM_ID", + "api_app_id": "A111", + "event": { + "file_id": "F111", + "user_id": "USER_ID_ACTOR", + "file": {"id": "F111"}, + "channel_id": "C111", + "type": "file_shared", + "event_ts": "1678454981.170300", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "INSTALLED_TEAM_ID", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "reaction_added", + "user": "USER_ID_ACTOR", + "reaction": "rocket", + "item": {"type": "message", "channel": "C111", "ts": "1678454602.316259"}, + "event_ts": "1678454724.000600", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message_metadata_posted", + "app_id": "A222", + "bot_id": "B222", + "user_id": "USER_ID_ACTOR", # Although this is always a bot's user ID, we can call it an actor + "team_id": "INSTALLED_TEAM_ID", + "channel_id": "C111", + "metadata": {"event_type": "task_created", "event_payload": {"id": "11223", "title": "Redesign Homepage"}}, + "message_ts": "1678458906.527119", + "event_ts": "1678458906.527119", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + ] + slack_connect_events_api_requests = [ + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "<@INSTALLED_BOT_USER_ID> Hey!", + "user": "USER_ID_ACTOR", + "ts": "1678455198.838499", + "team": "TEAM_ID_ACTOR", + "channel": "C111", + "event_ts": "1678455198.838499", + "channel_type": "channel", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message", + "text": "Hey!", + "user": "USER_ID_ACTOR", + "ts": "1678454365.204709", + "team": "TEAM_ID_ACTOR", + "channel": "C111", + "event_ts": "1678454365.204709", + "channel_type": "channel", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + { + "team_id": "TEAM_ID_ACTOR", + "enterprise_id": "ENTERPRISE_ID_ACTOR", + "context_team_id": "INSTALLED_TEAM_ID", + "context_enterprise_id": "INSTALLED_ENTERPRISE_ID", + "api_app_id": "A111", + "event": { + "type": "message", + "subtype": "channel_name", + "ts": "1678454602.316259", + "user": "USER_ID_ACTOR", + "text": "renamed", + "old_name": "old", + "name": "new", + "team": "TEAM_ID_ACTOR", + "channel": "C111", + "event_ts": "1678454602.316259", + "channel_type": "channel", + }, + "type": "event_callback", + "authorizations": slack_connect_authorizations, + "is_ext_shared_channel": True, + }, + ] + + def test_channel_id_extraction(self): + for req in self.requests: + channel_id = extract_channel_id(req) + assert channel_id == "C111" + + def test_user_id_extraction(self): + for req in self.requests: + user_id = extract_user_id(req) + assert user_id == "U111" + for req in self.enterprise_no_channel_requests: + user_id = extract_user_id(req) + assert user_id == "U111" + for req in self.no_enterprise_no_channel_requests: + user_id = extract_user_id(req) + assert user_id == "U111" + + def test_team_id_extraction(self): + for req in self.requests: + team_id = extract_team_id(req) + assert team_id == "T111" + for req in self.enterprise_no_channel_requests: + team_id = extract_team_id(req) + assert team_id == "T111" + for req in self.no_enterprise_no_channel_requests: + team_id = extract_team_id(req) + assert team_id == "T111" + for req in self.function_event_requests: + team_id = extract_team_id(req) + assert team_id == "T111" + + def test_enterprise_id_extraction(self): + for req in self.requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.enterprise_no_channel_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.no_enterprise_no_channel_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id is None + for req in self.function_event_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + + def test_bot_access_token_extraction(self): + for req in self.function_event_requests: + function_bot_access_token = extract_function_bot_access_token(req) + assert function_bot_access_token == "xwfp-xxx" + + def test_function_execution_id_extraction(self): + for req in self.function_event_requests: + function_execution_id = extract_function_execution_id(req) + assert function_execution_id == "Fx111" + + def test_function_inputs_extraction(self): + for req in self.function_event_requests: + inputs = extract_function_inputs(req) + assert inputs == {"customer_id": "Ux111"} + + def test_extract_thread_ts(self): + for req in self.thread_ts_event_requests: + thread_ts = extract_thread_ts(req) + assert thread_ts == "123.456", f"Expected thread_ts for {req}" + + def test_extract_thread_ts_fail(self): + for req in self.no_thread_ts_requests: + thread_ts = extract_thread_ts(req) + assert thread_ts is None, f"Expected None for {req}" + + def test_is_enterprise_install_extraction(self): + for req in self.requests: + should_be_false = extract_is_enterprise_install(req) + assert should_be_false is False + assert extract_is_enterprise_install({"is_enterprise_install": True}) is True + assert extract_is_enterprise_install({"is_enterprise_install": False}) is False + assert extract_is_enterprise_install({"is_enterprise_install": "true"}) is True + assert extract_is_enterprise_install({"is_enterprise_install": "false"}) is False + + def test_actor_enterprise_id(self): + for req in self.requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.slack_connect_events_api_requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id == "ENTERPRISE_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_team_requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id == "ENTERPRISE_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_enterprise_team_requests: + enterprise_id = extract_actor_enterprise_id(req) + assert enterprise_id is None + + def test_actor_team_id(self): + for req in self.requests: + team_id = extract_actor_team_id(req) + assert team_id == "T111" + for req in self.slack_connect_events_api_requests: + team_id = extract_actor_team_id(req) + assert team_id == "TEAM_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_team_requests: + team_id = extract_actor_team_id(req) + assert team_id is None + for req in self.slack_connect_events_api_no_actor_enterprise_team_requests: + team_id = extract_actor_team_id(req) + assert team_id is None + + def test_actor_user_id(self): + for req in self.requests: + user_id = extract_actor_user_id(req) + assert user_id == "U111" + for req in self.slack_connect_events_api_requests: + user_id = extract_actor_user_id(req) + assert user_id == "USER_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_team_requests: + user_id = extract_actor_user_id(req) + assert user_id == "USER_ID_ACTOR" + for req in self.slack_connect_events_api_no_actor_enterprise_team_requests: + user_id = extract_actor_user_id(req) + assert user_id is None + + def test_parse_query(self): + expected = {"foo": ["bar"], "baz": ["123"]} + + q = parse_query("foo=bar&baz=123") + assert q == expected + + q = parse_query({"foo": "bar", "baz": "123"}) + assert q == expected + + q = parse_query({"foo": ["bar"], "baz": ["123"]}) + assert q == expected + + with pytest.raises(ValueError): + parse_query({"foo": {"bar": "ZZZ"}, "baz": {"123": "111"}}) + + slack_connect_from_non_grid_test_patterns = [ + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + # context.enterprise_id/team_id/user_id, + (None, "T03E94MJU", "U03E94MK0"), + # context.actor_enterprise_id/team_id/user_id, + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "text": "<@U04T5KKKLUE>", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "user": "U03E94MK0", + "team": "T014GJXU940", + "user_team": "T03E94MJU", + "source_team": "T03E94MJU", + "user_profile": {"team": "T03E94MJU"}, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "subtype": "channel_join", + "user": "UL5CBM924", + "team": "T03E94MJU", + "inviter": "U03E94MK0", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T03E94MJU", + "context_enterprise_id": None, + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_joined_channel", + "user": "UL5CBM924", + "channel": "C04T3ACM40K", + "team": "T03E94MJU", + "inviter": "U03E94MK0", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T03E94MJU", + "context_enterprise_id": None, + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_left_channel", + "user": "UL5CBM924", + "channel": "C04T3ACM40K", + "team": "T03E94MJU", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "subtype": "channel_leave", + "user": "UL5CBM924", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "U03E94MK0", + "display_as_bot": False, + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TL3HA3PC", + "user_id": "U03E94MK0", + "file": {"id": "F04TL3HA3PC"}, + "channel_id": "C04T3ACM40K", + "type": "file_shared", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + # Note that a complete set of actor IDs are not deterministic in this scenario + # So, we fall back to all None data for clarity + (None, None, None), + ), + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TL3HA3PC", + "user_id": "U03E94MK0", + "file": {"id": "F04TL3HA3PC"}, + "type": "file_public", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message_metadata_posted", + "app_id": "A013TFN1T7C", + "bot_id": "B013ZM43W3E", + "user_id": "W013TN008CB", + "team_id": "T014GJXU940", + "channel_id": "C04T3ACM40K", + "metadata": { + "event_type": "task_created", + "event_payload": {"id": "11223", "title": "Redesign Homepage"}, + }, + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013TN008CB"), + # Note that a complete set of actor IDs are not deterministic in this scenario + # So, we fall back to all None data for clarity + (None, None, None), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "bot_id": "B013ZM43W3E", + "type": "message", + "user": "W013TN008CB", + "metadata": { + "event_type": "task_created", + "event_payload": {"id": "11223", "title": "Redesign Homepage"}, + }, + "app_id": "A013TFN1T7C", + "team": "T014GJXU940", + "bot_profile": { + "id": "B013ZM43W3E", + "app_id": "A013TFN1T7C", + "team_id": "T014GJXU940", + }, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013TN008CB"), + ("E013Y3SHLAY", "T014GJXU940", "W013TN008CB"), + ), + ] + + slack_connect_from_grid_test_patterns = [ + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "user": "W013QGS7BPF", + "team": "T03E94MJU", + "user_team": "E013Y3SHLAY", + "source_team": "E013Y3SHLAY", + "user_profile": { + "team": "E013Y3SHLAY", + }, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + # context.enterprise_id/team_id/user_id, + (None, "T03E94MJU", "W013QGS7BPF"), + # context.actor_enterprise_id/team_id/user_id, + ("E013Y3SHLAY", None, "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "W013QGS7BPF", + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TDEYDCT0", + "user_id": "W013QGS7BPF", + "file": {"id": "F04TDEYDCT0"}, + "channel_id": "C04T3ACM40K", + "type": "file_shared", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + (None, None, None), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "W013QGS7BPF", + "display_as_bot": False, + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TDEYDCT0", + "user_id": "W013QGS7BPF", + "file": {"id": "F04TDEYDCT0"}, + "type": "file_public", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": "E013Y3SHLAY", + "team_id": "T014GJXU940", + "user_id": "U04TDAM3YUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + }, + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_joined_channel", + "user": "W013CV5UA87", + "channel": "C04T3ACM40K", + "team": "T014GJXU940", + "inviter": "W013QGS7BPF", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013CV5UA87"), + ("E013Y3SHLAY", "T014GJXU940", "W013CV5UA87"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_left_channel", + "user": "W013CV5UA87", + "channel": "C04T3ACM40K", + "team": "E013Y3SHLAY", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013CV5UA87"), + ("E013Y3SHLAY", "T014GJXU940", "W013CV5UA87"), + ), + ] + + def test_slack_connect_patterns(self): + for ( + request, + (enterprise_id, team_id, user_id), + (actor_enterprise_id, actor_team_id, actor_user_id), + ) in self.slack_connect_from_non_grid_test_patterns: + assert extract_enterprise_id(request) == enterprise_id + assert extract_team_id(request) == team_id + assert extract_user_id(request) == user_id + assert extract_actor_enterprise_id(request) == actor_enterprise_id + assert extract_actor_team_id(request) == actor_team_id + assert extract_actor_user_id(request) == actor_user_id + + for ( + request, + (enterprise_id, team_id, user_id), + (actor_enterprise_id, actor_team_id, actor_user_id), + ) in self.slack_connect_from_grid_test_patterns: + assert extract_enterprise_id(request) == enterprise_id + assert extract_team_id(request) == team_id + assert extract_user_id(request) == user_id + assert extract_actor_enterprise_id(request) == actor_enterprise_id + assert extract_actor_team_id(request) == actor_team_id + assert extract_actor_user_id(request) == actor_user_id + + 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/request/test_request.py b/tests/slack_bolt/request/test_request.py new file mode 100644 index 000000000..e091f9a21 --- /dev/null +++ b/tests/slack_bolt/request/test_request.py @@ -0,0 +1,242 @@ +from urllib.parse import quote + +from slack_bolt.request.request import BoltRequest + + +class TestRequest: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_all_none_inputs_http(self): + req = BoltRequest(body=None, headers=None, query=None, context=None) + assert req is not None + assert req.raw_body == "" + assert req.body == {} + + def test_all_none_inputs_socket_mode(self): + req = BoltRequest(body=None, headers=None, query=None, context=None, mode="socket_mode") + assert req is not None + assert req.raw_body == "" + assert req.body == {} + + def test_org_wide_installations_block_actions(self): + payload = """ +{ + "type": "block_actions", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T_expected" + }, + "api_app_id": "A111", + "token": "fixed-value", + "container": { + "type": "message", + "message_ts": "1643113871.000700", + "channel_id": "C111", + "is_ephemeral": true + }, + "trigger_id": "111.222.xxx", + "team": null, + "enterprise": { + "id": "E111", + "name": "Sandbox Org" + }, + "is_enterprise_install": true, + "channel": { + "id": "C111", + "name": "random" + }, + "state": { + "values": {} + }, + "response_url": "https://hooks.slack.com/actions/E111/111/xxx", + "actions": [ + { + "action_id": "a", + "block_id": "b", + "text": { + "type": "plain_text", + "text": "Button" + }, + "value": "click_me_123", + "type": "button", + "action_ts": "1643113877.645417" + } + ] +} +""" + req = BoltRequest(body=f"payload={quote(payload)}") + assert req is not None + assert req.context.team_id == "T_expected" + assert req.context.user_id == "W111" + + def test_org_wide_installations_view_submission(self): + payload = """ +{ + "type": "view_submission", + "team": null, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T_unexpected" + }, + "api_app_id": "A111", + "token": "fixed-value", + "trigger_id": "1111.222.xxx", + "view": { + "id": "V111", + "team_id": "T_unexpected", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "+5B", + "label": { + "type": "plain_text", + "text": "Label", + "emoji": true + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "plain_text_input", + "dispatch_action_config": { + "trigger_actions_on": [ + "on_enter_pressed" + ] + }, + "action_id": "MMKH" + } + } + ], + "private_metadata": "", + "callback_id": "view-id", + "state": { + "values": { + "+5B": { + "MMKH": { + "type": "plain_text_input", + "value": "test" + } + } + } + }, + "hash": "111.xxx", + "title": { + "type": "plain_text", + "text": "My App" + }, + "clear_on_close": false, + "notify_on_close": false, + "close": { + "type": "plain_text", + "text": "Cancel" + }, + "submit": { + "type": "plain_text", + "text": "Submit", + "emoji": true + }, + "previous_view_id": null, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T_expected", + "bot_id": "B111" + }, + "response_urls": [], + "is_enterprise_install": true, + "enterprise": { + "id": "E111", + "name": "Sandbox Org" + } +} +""" + req = BoltRequest(body=f"payload={quote(payload)}") + assert req is not None + assert req.context.team_id == "T_expected" + assert req.context.user_id == "W111" + + def test_org_wide_installations_view_closed(self): + payload = """ +{ + "type": "view_closed", + "team": null, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T_unexpected" + }, + "api_app_id": "A111", + "token": "fixed-value", + "view": { + "id": "V111", + "team_id": "T_unexpected", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "M2r2p", + "label": { + "type": "plain_text", + "text": "Label" + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "plain_text_input", + "dispatch_action_config": { + "trigger_actions_on": [ + "on_enter_pressed" + ] + }, + "action_id": "xB+" + } + } + ], + "private_metadata": "", + "callback_id": "view-id", + "state": { + "values": {} + }, + "hash": "1643113987.gRY6ROtt", + "title": { + "type": "plain_text", + "text": "My App" + }, + "clear_on_close": false, + "notify_on_close": true, + "close": { + "type": "plain_text", + "text": "Cancel" + }, + "submit": { + "type": "plain_text", + "text": "Submit" + }, + "previous_view_id": null, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T_expected", + "bot_id": "B0302M47727" + }, + "is_cleared": false, + "is_enterprise_install": true, + "enterprise": { + "id": "E111", + "name": "Sandbox Org" + } +} +""" + req = BoltRequest(body=f"payload={quote(payload)}") + assert req is not None + assert req.context.team_id == "T_expected" + assert req.context.user_id == "W111" diff --git a/tests/slack_bolt/util/__init__.py b/tests/slack_bolt/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/util/test_util.py b/tests/slack_bolt/util/test_util.py new file mode 100644 index 000000000..0a5a43f7b --- /dev/null +++ b/tests/slack_bolt/util/test_util.py @@ -0,0 +1,57 @@ +import sys +from typing import Set + +import pytest +from slack_sdk.models import JsonObject + +from slack_bolt.error import BoltError +from slack_bolt.util.utils import convert_to_dict, get_boot_message +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class Data: + def __init__(self, name: str): + self.name = name + + +class SerializableData(JsonObject): + @property + def attributes(self) -> Set[str]: + return {"name"} + + def __init__(self, name: str): + self.name = name + + +class TestUtil: + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + restore_os_env(self.old_os_env) + + def test_convert_to_dict(self): + assert convert_to_dict({"foo": "bar"}) == {"foo": "bar"} + assert convert_to_dict(SerializableData("baz")) == {"name": "baz"} + + def test_convert_to_dict_errors(self): + with pytest.raises(BoltError): + convert_to_dict(None) + with pytest.raises(BoltError): + convert_to_dict(123) + with pytest.raises(BoltError): + convert_to_dict("test") + with pytest.raises(BoltError): + convert_to_dict(Data("baz")) + + def test_get_boot_message(self): + assert get_boot_message() == "⚡️ Bolt app is running!" + assert get_boot_message(development_server=True) == "⚡️ Bolt app is running! (development server)" + + def test_get_boot_message_win32(self): + sys_platform_backup = sys.platform + try: + sys.platform = "win32" + assert get_boot_message() == "Bolt app is running!" + finally: + sys.platform = sys_platform_backup diff --git a/tests/slack_bolt/workflows/__init__.py b/tests/slack_bolt/workflows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/workflows/step/__init__.py b/tests/slack_bolt/workflows/step/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt/workflows/step/test_step.py b/tests/slack_bolt/workflows/step/test_step.py new file mode 100644 index 000000000..107154439 --- /dev/null +++ b/tests/slack_bolt/workflows/step/test_step.py @@ -0,0 +1,39 @@ +import pytest + +from slack_bolt import Ack +from slack_bolt.error import BoltError +from slack_bolt.workflows.step import WorkflowStep + + +class TestStep: + def test_build(self): + step = WorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.execute(just_ack) + assert step.build() is not None + + def test_build_errors(self): + with pytest.raises(BoltError): + step = WorkflowStep.builder("foo") + step.save(just_ack) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = WorkflowStep.builder("foo") + step.edit(just_ack) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = WorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.build() + + +def just_ack(ack: Ack): + ack() + + +def execute(): + pass diff --git a/tests/slack_bolt_async/__init__.py b/tests/slack_bolt_async/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/app/__init__.py b/tests/slack_bolt_async/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/app/test_server.py b/tests/slack_bolt_async/app/test_server.py new file mode 100644 index 000000000..0c53f96c4 --- /dev/null +++ b/tests/slack_bolt_async/app/test_server.py @@ -0,0 +1,21 @@ +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.app.async_server import AsyncSlackAppServer + + +class TestServer: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_instance(self): + server = AsyncSlackAppServer( + port=3001, + path="/slack/events", + app=AsyncApp( + signing_secret="valid", + token="xoxb-valid", + ), + ) + assert server is not None diff --git a/tests/slack_bolt_async/authorization/__init__.py b/tests/slack_bolt_async/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/authorization/test_async_authorize.py b/tests/slack_bolt_async/authorization/test_async_authorize.py new file mode 100644 index 000000000..d98a1062f --- /dev/null +++ b/tests/slack_bolt_async/authorization/test_async_authorize.py @@ -0,0 +1,509 @@ +import datetime +import logging +from logging import Logger +from typing import Optional + +import pytest +from slack_sdk.oauth.installation_store import Bot, Installation +from slack_sdk.oauth.installation_store.async_installation_store import ( + AsyncInstallationStore, +) +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.authorization.async_authorize import ( + AsyncInstallationStoreAuthorize, + AsyncAuthorize, +) +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.error import BoltError +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncAuthorize: + mock_api_server_base_url = "http://localhost:8888" + client = AsyncWebClient( + base_url=mock_api_server_base_url, + ) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + try: + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_root_class(self): + authorize = AsyncAuthorize() + with pytest.raises(NotImplementedError): + await authorize( + context=AsyncBoltContext(), + enterprise_id="T111", + team_id="T111", + user_id="U111", + ) + + @pytest.mark.asyncio + async def test_installation_store_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + assert authorize.find_installation_available is None + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) + + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 2) + + @pytest.mark.asyncio + async def test_installation_store_cached_legacy(self): + installation_store = LegacyMemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + assert authorize.find_installation_available is None + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is False + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) + + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) # cached + + @pytest.mark.asyncio + async def test_installation_store_bot_only(self): + installation_store = BotOnlyMemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + bot_only=True, + ) + assert authorize.find_installation_available is None + assert authorize.bot_only is True + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) + + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 2) + + @pytest.mark.asyncio + async def test_installation_store_cached_bot_only(self): + installation_store = BotOnlyMemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + bot_only=True, + ) + assert authorize.find_installation_available is None + assert authorize.bot_only is True + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) + + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) # cached + + @pytest.mark.asyncio + async def test_installation_store(self): + installation_store = MemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + assert authorize.find_installation_available is None + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 2) + + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + await assert_auth_test_count_async(self, 4) + + @pytest.mark.asyncio + async def test_installation_store_cached(self): + installation_store = MemoryInstallationStore() + authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, + installation_store=installation_store, + cache_enabled=True, + ) + assert authorize.find_installation_available is None + context = AsyncBoltContext() + context["client"] = self.client + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert authorize.find_installation_available is True + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 2) + + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W11111") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 2) # cached + + @pytest.mark.asyncio + async def test_fetch_different_user_token(self): + installation_store = ValidUserTokenInstallationStore() + authorize = AsyncInstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + context = AsyncBoltContext() + context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url) + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token == "xoxp-valid" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 2) + + @pytest.mark.asyncio + async def test_fetch_different_user_token_with_rotation(self): + context = AsyncBoltContext() + mock_client = AsyncWebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + await invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + ) + + authorize = AsyncInstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W222") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token == "xoxp-valid-refreshed" + assert result.user_id == "W99999" + assert result.user == "some-user" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 2) + + @pytest.mark.asyncio + async def test_remove_latest_user_token_if_it_is_not_relevant(self): + installation_store = ValidUserTokenInstallationStore() + authorize = AsyncInstallationStoreAuthorize(logger=installation_store.logger, installation_store=installation_store) + context = AsyncBoltContext() + context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url) + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_rotate_only_bot_token(self): + context = AsyncBoltContext() + mock_client = AsyncWebClient(base_url=self.mock_api_server_base_url) + context["client"] = mock_client + + installation_store = ValidUserTokenRotationInstallationStore() + invalid_authorize = AsyncInstallationStoreAuthorize( + logger=installation_store.logger, installation_store=installation_store + ) + with pytest.raises(BoltError): + await invalid_authorize( + context=context, + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W333", + ) + + authorize = AsyncInstallationStoreAuthorize( + client_id="111.222", + client_secret="secret", + client=mock_client, + logger=installation_store.logger, + installation_store=installation_store, + ) + result = await authorize(context=context, enterprise_id="E111", team_id="T0G9PQBBK", user_id="W333") + assert result.bot_id == "BZYBOTHED" + assert result.bot_user_id == "W23456789" + assert result.bot_token == "xoxb-valid-refreshed" + assert result.user_token is None + assert result.user_id is None + assert result.user == "bot" + assert result.team_id == "T0G9PQBBK" + assert result.team == "Subarachnoid Workspace" + assert result.url == "https://subarachnoid.slack.com/" + await assert_auth_test_count_async(self, 1) + + +class LegacyMemoryInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_bot( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Bot]: + return Bot( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class MemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid-2", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class BotOnlyMemoryInstallationStore(LegacyMemoryInstallationStore): + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + raise ValueError + + +class ValidUserTokenInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + + +class ValidUserTokenRotationInstallationStore(AsyncInstallationStore): + @property + def logger(self) -> Logger: + return logging.getLogger(__name__) + + async def async_save(self, installation: Installation): + pass + + async def async_find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + is_enterprise_install: Optional[bool] = False, + ) -> Optional[Installation]: + if user_id is None: + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + bot_token="xoxb-valid", + bot_refresh_token="xoxe-bot-valid", + bot_token_expires_in=-10, + bot_id="B", + bot_user_id="W", + bot_scopes=["commands", "chat:write"], + user_id="W11111", + user_token="xoxp-different-installer", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) + elif user_id == "W222": + return Installation( + app_id="A111", + enterprise_id="E111", + team_id="T0G9PQBBK", + user_id="W222", + user_token="xoxp-valid", + user_refresh_token="xoxe-1-user-valid", + user_token_expires_in=-10, + user_scopes=["search:read"], + installed_at=datetime.datetime.now().timestamp(), + ) diff --git a/tests/slack_bolt_async/context/__init__.py b/tests/slack_bolt_async/context/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/context/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py new file mode 100644 index 000000000..bbd6db41d --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -0,0 +1,204 @@ +import pytest +from slack_sdk.models.blocks import PlainTextObject, DividerBlock +from slack_sdk.models.views import View + +from slack_bolt import BoltResponse +from slack_bolt.context.ack.async_ack import AsyncAck + + +class TestAsyncAsyncAck: + @pytest.mark.asyncio + async def test_text(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo") + assert (response.status, response.body) == (200, "foo") + + @pytest.mark.asyncio + async def test_blocks(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", blocks=[{"type": "divider"}]) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "blocks": [{"type": "divider"}]}', + ) + + @pytest.mark.asyncio + async def test_unfurl_options(self): + ack = AsyncAck() + response: BoltResponse = await ack( + text="foo", + blocks=[{"type": "divider"}], + unfurl_links=True, + unfurl_media=True, + ) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "unfurl_links": true, "unfurl_media": true, "blocks": [{"type": "divider"}]}', + ) + + sample_attachments = [ + { + "fallback": "Plain-text summary of the attachment.", + "color": "#2eb886", + "pretext": "Optional text that appears above the attachment block", + "author_name": "Bobby Tables", + "author_link": "http://flickr.com/bobby/", + "author_icon": "http://flickr.com/icons/bobby.jpg", + "title": "Slack API Documentation", + "title_link": "https://api.slack.com/", + "text": "Optional text that appears within the attachment", + "fields": [{"title": "Priority", "value": "High", "short": False}], + "image_url": "http://my-website.com/path/to/image.jpg", + "thumb_url": "http://example.com/path/to/thumb.png", + "footer": "Slack API", + "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", + "ts": 123456789, + } + ] + + @pytest.mark.asyncio + async def test_attachments(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", attachments=self.sample_attachments) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", ' + '"attachments": [{"fallback": "Plain-text summary of the attachment.", "color": "#2eb886", "pretext": "Optional text that appears above the attachment block", "author_name": "Bobby Tables", "author_link": "http://flickr.com/bobby/", "author_icon": "http://flickr.com/icons/bobby.jpg", "title": "Slack API Documentation", "title_link": "https://api.slack.com/", "text": "Optional text that appears within the attachment", "fields": [{"title": "Priority", "value": "High", "short": false}], "image_url": "http://my-website.com/path/to/image.jpg", "thumb_url": "http://example.com/path/to/thumb.png", "footer": "Slack API", "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", "ts": 123456789}]' + "}", + ) + + sample_options = [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] + + @pytest.mark.asyncio + async def test_options(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", options=self.sample_options) + assert response.status == 200 + assert response.body == '{"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}' + + sample_option_groups = [ + { + "label": {"type": "plain_text", "text": "Group 1"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "1-1"}, + {"text": {"type": "plain_text", "text": "Option 2"}, "value": "1-2"}, + ], + }, + { + "label": {"type": "plain_text", "text": "Group 2"}, + "options": [ + {"text": {"type": "plain_text", "text": "Option 1"}, "value": "2-1"}, + ], + }, + ] + + @pytest.mark.asyncio + async def test_option_groups(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", option_groups=self.sample_option_groups) + assert response.status == 200 + assert response.body.startswith('{"option_groups":') + + @pytest.mark.asyncio + async def test_response_type(self): + ack = AsyncAck() + response: BoltResponse = await ack(text="foo", response_type="in_channel") + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "response_type": "in_channel"}', + ) + + @pytest.mark.asyncio + async def test_dialog_errors(self): + expected_body = '{"errors": [{"name": "loc_origin", "error": "Pickup Location must be longer than 3 characters"}]}' + errors = [ + { + "name": "loc_origin", + "error": "Pickup Location must be longer than 3 characters", + } + ] + + ack = AsyncAck() + response: BoltResponse = await ack(errors=errors) + assert (response.status, response.body) == (200, expected_body) + response: BoltResponse = await ack({"errors": errors}) + assert (response.status, response.body) == (200, expected_body) + + @pytest.mark.asyncio + async def test_view_errors(self): + ack = AsyncAck() + response: BoltResponse = await ack( + response_action="errors", + errors={ + "block_title": "Title is required", + "block_description": "Description must be longer than 10 characters", + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "errors", ' + '"errors": {' + '"block_title": "Title is required", ' + '"block_description": "Description must be longer than 10 characters"' + "}" + "}", + ) + + @pytest.mark.asyncio + async def test_view_update(self): + ack = AsyncAck() + response: BoltResponse = await ack( + response_action="update", + view={ + "type": "modal", + "callbAsyncAck_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [{"type": "divider", "block_id": "b"}], + }, + ) + assert (response.status, response.body) == ( + 200, + '{"response_action": "update", ' + '"view": {' + '"type": "modal", ' + '"callbAsyncAck_id": "view-id", ' + '"title": {"type": "plain_text", "text": "My App"}, ' + '"close": {"type": "plain_text", "text": "Cancel"}, ' + '"blocks": [{"type": "divider", "block_id": "b"}]' + "}" + "}", + ) + + @pytest.mark.asyncio + async def test_view_update_2(self): + ack = AsyncAck() + response: BoltResponse = await ack( + response_action="update", + view=View( + type="modal", + callback_id="view-id", + title=PlainTextObject(text="My App"), + close=PlainTextObject(text="Cancel"), + blocks=[DividerBlock(block_id="b")], + ), + ) + assert (response.status, response.body) == ( + 200, + "" + '{"response_action": "update", ' + '"view": {' + '"blocks": [{"block_id": "b", "type": "divider"}], ' + '"callback_id": "view-id", ' + '"close": {"text": "Cancel", "type": "plain_text"}, ' + '"title": {"text": "My App", "type": "plain_text"}, ' + '"type": "modal"' + "}" + "}", + ) diff --git a/tests/slack_bolt_async/context/test_async_complete.py b/tests/slack_bolt_async/context/test_async_complete.py new file mode 100644 index 000000000..4277d4218 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_complete.py @@ -0,0 +1,52 @@ +import pytest +import asyncio + +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.complete.async_complete import AsyncComplete +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 TestAsyncComplete: + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_complete(self): + complete_success = AsyncComplete(client=self.web_client, function_execution_id="fn1111") + + response = await complete_success(outputs={"key": "value"}) + + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_complete_no_function_execution_id(self): + complete = AsyncComplete(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + await complete(outputs={"key": "value"}) + + @pytest.mark.asyncio + async def test_has_been_called_false_initially(self): + complete = AsyncComplete(client=self.web_client, function_execution_id="fn1111") + assert complete.has_been_called() is False + + @pytest.mark.asyncio + async def test_has_been_called_true_after_complete(self): + complete = AsyncComplete(client=self.web_client, function_execution_id="fn1111") + await complete(outputs={"key": "value"}) + assert complete.has_been_called() is True diff --git a/tests/slack_bolt_async/context/test_async_fail.py b/tests/slack_bolt_async/context/test_async_fail.py new file mode 100644 index 000000000..d344a6c95 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_fail.py @@ -0,0 +1,51 @@ +import pytest +import asyncio + +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.fail.async_fail import AsyncFail +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 TestAsyncFail: + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_fail(self): + fail = AsyncFail(client=self.web_client, function_execution_id="fn1111") + + response = await fail(error="something went wrong") + + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_fail_no_function_execution_id(self): + fail = AsyncFail(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + await fail(error="there was an error") + + @pytest.mark.asyncio + async def test_has_been_called_false_initially(self): + fail = AsyncFail(client=self.web_client, function_execution_id="fn1111") + assert fail.has_been_called() is False + + @pytest.mark.asyncio + async def test_has_been_called_true_after_fail(self): + fail = AsyncFail(client=self.web_client, function_execution_id="fn1111") + await fail(error="there was an error") + assert fail.has_been_called() is True diff --git a/tests/slack_bolt_async/context/test_async_respond.py b/tests/slack_bolt_async/context/test_async_respond.py new file mode 100644 index 000000000..b47ef1056 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_respond.py @@ -0,0 +1,53 @@ +import pytest + +from tests.utils import remove_os_env_temporarily, restore_os_env +from slack_bolt.context.respond.async_respond import AsyncRespond +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) + + +class TestAsyncRespond: + + @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 # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_respond(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond(text="Hi there!") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_respond2(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond({"text": "Hi there!"}) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_respond_unfurl_options(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond(text="Hi there!", unfurl_media=True, unfurl_links=True) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_metadata(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond( + text="Hi there!", + response_type="in_channel", + metadata={"event_type": "foo", "event_payload": {"foo": "bar"}}, + ) + assert response.status_code == 200 diff --git a/tests/slack_bolt_async/context/test_async_say.py b/tests/slack_bolt_async/context/test_async_say.py new file mode 100644 index 000000000..d8d63ae8a --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -0,0 +1,79 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from slack_bolt.context.say.async_say import AsyncSay +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncSay: + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_say(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(text="Hi there!") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_markdown_text(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(markdown_text="**Greetings!**") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_unfurl_options(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(text="Hi there!", unfurl_links=True, unfurl_media=True) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_reply_in_thread(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(text="Hi there!", thread_ts="111.222", reply_broadcast=True) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_dict(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say({"text": "Hi!"}) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_dict_channel(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say({"text": "Hi!", "channel": "C111"}) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_invalid(self): + say = AsyncSay(client=self.web_client, channel="C111") + with pytest.raises(ValueError): + await say([]) + + @pytest.mark.asyncio + async def test_say_shared_dict_as_arg(self): + # this shared dict object must not be modified by say method + shared_template_dict = {"text": "Hi there!"} + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(shared_template_dict) + assert response.status_code == 200 + assert shared_template_dict.get("channel") is None + + say = AsyncSay(client=self.web_client, channel="C222") + response: AsyncSlackResponse = await say(shared_template_dict) + assert response.status_code == 200 + assert shared_template_dict.get("channel") is None 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 new file mode 100644 index 000000000..bcf1fcf19 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_set_status.py @@ -0,0 +1,57 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncSetStatus: + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_set_status(self): + set_status = AsyncSetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_status("Thinking...") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_status_loading_messages(self): + set_status = AsyncSetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_status( + status="Thinking...", + loading_messages=[ + "Sitting...", + "Waiting...", + ], + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_status_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") + with pytest.raises(TypeError): + await set_status() diff --git a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py new file mode 100644 index 000000000..7a92ff2a3 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py @@ -0,0 +1,108 @@ +import asyncio +from unittest.mock import MagicMock, patch + +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncSetSuggestedPrompts: + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_set_suggested_prompts(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_suggested_prompts(prompts=["One", "Two"]) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_suggested_prompts_objects(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: AsyncSlackResponse = await set_suggested_prompts( + prompts=[ + "One", + {"title": "Two", "message": "What's before addition?"}, + ], + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_suggested_prompts_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") + with pytest.raises(TypeError): + await set_suggested_prompts() diff --git a/tests/slack_bolt_async/kwargs_injection/__init__.py b/tests/slack_bolt_async/kwargs_injection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/kwargs_injection/test_async_args.py b/tests/slack_bolt_async/kwargs_injection/test_async_args.py new file mode 100644 index 000000000..f0322f620 --- /dev/null +++ b/tests/slack_bolt_async/kwargs_injection/test_async_args.py @@ -0,0 +1,62 @@ +import logging + +from slack_bolt import BoltResponse +from slack_bolt.kwargs_injection.async_args import AsyncArgs +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs +from slack_bolt.request.async_request import AsyncBoltRequest + + +class TestAsyncArgs: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def next(self): + pass + + def test_build(self): + required_args = [ + "logger", + "client", + "req", + "resp", + "context", + "body", + "payload", + "ack", + "say", + "respond", + "complete", + "fail", + "next", + ] + arg_params: dict = build_async_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=AsyncBoltRequest(body="", headers={}), + response=BoltResponse(status=200), + next_func=next, + ) + args = AsyncArgs(**arg_params) + assert args.logger is not None + assert args.request is not None + assert args.response is not None + assert args.client is not None + + def test_all_values_from_context(self): + req = AsyncBoltRequest(body="", headers={}) + req.context["foo"] = "FOO" + req.context["bar"] = 123 + required_args = ["foo", "bar", "ack"] + arg_params: dict = build_async_required_kwargs( + logger=logging.getLogger(__name__), + required_arg_names=required_args, + request=req, + response=BoltResponse(status=200), + next_func=next, + ) + assert arg_params["foo"] == "FOO" + assert arg_params["bar"] == 123 + assert arg_params["ack"] is not None diff --git a/tests/slack_bolt_async/listener/__init__.py b/tests/slack_bolt_async/listener/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/listener/test_async_listener_completion_handler.py b/tests/slack_bolt_async/listener/test_async_listener_completion_handler.py new file mode 100644 index 000000000..c03789329 --- /dev/null +++ b/tests/slack_bolt_async/listener/test_async_listener_completion_handler.py @@ -0,0 +1,29 @@ +import logging +import pytest + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.listener.async_listener_completion_handler import ( + AsyncCustomListenerCompletionHandler, +) + + +class TestAsyncListenerCompletionHandler: + @pytest.mark.asyncio + async def test_handler(self): + result = {"called": False} + + async def f(): + result["called"] = True + + handler = AsyncCustomListenerCompletionHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = AsyncBoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + await handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt_async/listener/test_async_listener_start_handler.py b/tests/slack_bolt_async/listener/test_async_listener_start_handler.py new file mode 100644 index 000000000..0a69a671d --- /dev/null +++ b/tests/slack_bolt_async/listener/test_async_listener_start_handler.py @@ -0,0 +1,29 @@ +import logging +import pytest + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.listener.async_listener_start_handler import ( + AsyncCustomListenerStartHandler, +) + + +class TestAsyncListenerStartHandler: + @pytest.mark.asyncio + async def test_handler(self): + result = {"called": False} + + async def f(): + result["called"] = True + + handler = AsyncCustomListenerStartHandler( + logger=logging.getLogger(__name__), + func=f, + ) + request = AsyncBoltRequest( + body="{}", + query={}, + headers={"content-type": ["application/json"]}, + context={}, + ) + await handler.handle(request, None) + assert result["called"] is True diff --git a/tests/slack_bolt_async/listener_matcher/__init__.py b/tests/slack_bolt_async/listener_matcher/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py new file mode 100644 index 000000000..96a407aab --- /dev/null +++ b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py @@ -0,0 +1,34 @@ +import pytest + +from slack_bolt.listener_matcher.async_listener_matcher import ( + AsyncCustomListenerMatcher, + AsyncListenerMatcher, +) +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +async def func(body, request, response, dummy): + assert body is not None + assert request is not None + assert response is not None + assert dummy is None + return body["result"] + + +class TestAsyncCustomListenerMatcher: + @pytest.mark.asyncio + async def test_instantiation(self): + matcher: AsyncListenerMatcher = AsyncCustomListenerMatcher( + app_name="foo", + func=func, + ) + resp = BoltResponse(status=201) + + req = AsyncBoltRequest(body='payload={"result":true}') + result = await matcher.async_matches(req, resp) + assert result is True + + req = AsyncBoltRequest(body='payload={"result":false}') + result = await matcher.async_matches(req, resp) + assert result is False diff --git a/tests/slack_bolt_async/logger/__init__.py b/tests/slack_bolt_async/logger/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/logger/test_unmatched_suggestions.py b/tests/slack_bolt_async/logger/test_unmatched_suggestions.py new file mode 100644 index 000000000..d8c659892 --- /dev/null +++ b/tests/slack_bolt_async/logger/test_unmatched_suggestions.py @@ -0,0 +1,885 @@ +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.logger.messages import warning_unhandled_request + + +class TestUnmatchedPatternSuggestions: + def setup_method(self): + pass + + def teardown_method(self): + pass + + def test_unknown_patterns(self): + req: AsyncBoltRequest = AsyncBoltRequest(body={"type": "foo"}, mode="socket_mode") + message = warning_unhandled_request(req) + assert f"Unhandled request ({req.body})" == message + + def test_block_actions(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=block_actions, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "block_actions", + "block_id": "b", + "action_id": "action-id-value", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.action("action-id-value") +async def handle_some_action(ack, body, logger): + await ack() + logger.info(body) +""" == message + + def test_attachment_actions(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=attachment_actions, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "interactive_message", + "callback_id": "pick_channel_for_fun", + "actions": [ + { + "name": "channel_list", + "type": "select", + "selected_options": [{"value": "C111"}], + } + ], + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.action("pick_channel_for_fun") +async def handle_some_action(ack, body, logger): + await ack() + logger.info(body) +""" == message + + def test_app_mention_event(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=app_mention_event, mode="socket_mode") + filtered_body = { + "type": "event_callback", + "event": {"type": "app_mention"}, + } + message = warning_unhandled_request(req) + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.event("app_mention") +async def handle_app_mention_events(body, logger): + logger.info(body) +""" == message + + def test_function_event(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=function_event, mode="socket_mode") + filtered_body = { + "type": "event_callback", + "event": {"type": "function_executed"}, + } + message = warning_unhandled_request(req) + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.function("reverse") +async def handle_some_function(ack, body, complete, fail, logger): + await ack() + logger.info(body) + try: + # TODO: do something here + outputs = {{}} + await complete(outputs=outputs) + except Exception as e: + error = f"Failed to handle a function request (error: {{e}})" + await fail(error=error) +""" == message + + def test_commands(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=slash_command, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": None, + "command": "/start-conv", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.command("/start-conv") +async def handle_some_command(ack, body, logger): + await ack() + logger.info(body) +""" == message + + def test_shortcut(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=global_shortcut, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "shortcut", + "callback_id": "test-shortcut", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.shortcut("test-shortcut") +async def handle_shortcuts(ack, body, logger): + await ack() + logger.info(body) +""" == message + + req: AsyncBoltRequest = AsyncBoltRequest(body=message_shortcut, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "message_action", + "callback_id": "test-shortcut", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.shortcut("test-shortcut") +async def handle_shortcuts(ack, body, logger): + await ack() + logger.info(body) +""" == message + + def test_view(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=view_submission, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_submission", + "view": {"type": "modal", "callback_id": "view-id"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.view("view-id") +async def handle_view_submission_events(ack, body, logger): + await ack() + logger.info(body) +""" == message + + req: AsyncBoltRequest = AsyncBoltRequest(body=view_closed, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_closed", + "view": {"type": "modal", "callback_id": "view-id"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.view_closed("view-id") +async def handle_view_closed_events(ack, body, logger): + await ack() + logger.info(body) +""" == message + + def test_block_suggestion(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=block_suggestion, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "block_suggestion", + "view": {"type": "modal", "callback_id": "view-id"}, + "block_id": "block-id", + "action_id": "the-id", + "value": "search word", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.options("the-id") +async def handle_some_options(ack): + await ack(options=[ ... ]) +""" == message + + def test_dialog_suggestion(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=dialog_suggestion, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "dialog_suggestion", + "callback_id": "the-id", + "value": "search keyword", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.options({{"type": "dialog_suggestion", "callback_id": "the-id"}}) +async def handle_some_options(ack): + await ack(options=[ ... ]) +""" == message + + def test_step(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=step_edit_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "workflow_step_edit", + "callback_id": "copy_review", + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +ws = AsyncWorkflowStep( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" == message + req: AsyncBoltRequest = AsyncBoltRequest(body=step_save_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "view_submission", + "view": {"type": "workflow_step", "callback_id": "copy_review"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +ws = AsyncWorkflowStep( + callback_id="copy_review", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" == message + req: AsyncBoltRequest = AsyncBoltRequest(body=step_execute_payload, mode="socket_mode") + message = warning_unhandled_request(req) + filtered_body = { + "type": "event_callback", + "event": {"type": "workflow_step_execute"}, + } + assert f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +ws = AsyncWorkflowStep( + callback_id="your-callback-id", + edit=edit, + save=save, + execute=execute, +) +# Pass Step to set up listeners +app.step(ws) +""" == message + + +block_actions = { + "type": "block_actions", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "111.222", + "channel_id": "C111", + "is_ephemeral": True, + }, + "trigger_id": "111.222.valid", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "channel": {"id": "C111", "name": "test-channel"}, + "response_url": "https://hooks.slack.com/actions/T111/111/random-value", + "actions": [ + { + "action_id": "action-id-value", + "block_id": "b", + "text": {"type": "plain_text", "text": "Button", "emoji": True}, + "value": "click_me_123", + "type": "button", + "action_ts": "1596530385.194939", + } + ], +} + +attachment_actions = { + "type": "interactive_message", + "actions": [ + { + "name": "channel_list", + "type": "select", + "selected_options": [{"value": "C111"}], + } + ], + "callback_id": "pick_channel_for_fun", + "team": {"id": "T111", "domain": "hooli-hq"}, + "channel": {"id": "C222", "name": "triage-random"}, + "user": {"id": "U111", "name": "gbelson"}, + "action_ts": "1520966872.245369", + "message_ts": "1520965348.000538", + "attachment_id": "1", + "token": "verification_token", + "is_app_unfurl": True, + "original_message": { + "text": "", + "username": "Belson Bot", + "bot_id": "B111", + "attachments": [ + { + "callback_id": "pick_channel_for_fun", + "text": "Choose a channel", + "id": 1, + "color": "2b72cb", + "actions": [ + { + "id": "1", + "name": "channel_list", + "text": "Public channels", + "type": "select", + "data_source": "channels", + } + ], + "fallback": "Choose a channel", + } + ], + "type": "message", + "subtype": "bot_message", + "ts": "1520965348.000538", + }, + "response_url": "https://hooks.slack.com/actions/T111/111/xxxx", + "trigger_id": "111.222.valid", +} + + +app_mention_event = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, +} + +function_event = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + +slash_command = { + "token": "fixed-verification-token", + "team_id": "T111", + "team_domain": "maria", + "channel_id": "C111", + "channel_name": "general", + "user_id": "U111", + "user_name": "rainer", + "command": "/start-conv", + "text": "title", + "response_url": "https://xxx.slack.com/commands/T111/xxx/zzz", + "trigger_id": "111.222.xxx", +} + +step_edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +step_save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +step_execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + +global_shortcut = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", +} + +message_shortcut = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", +} + +view_submission = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "trigger_id": "111.222.valid", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": {"values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}}}, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +view_closed = { + "type": "view_closed", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification_token", + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "hspI", + "label": { + "type": "plain_text", + "text": "Label", + }, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "maBWU"}, + } + ], + "private_metadata": "This is for you!", + "callback_id": "view-id", + "state": {"values": {}}, + "hash": "1596530361.3wRYuk3R", + "title": { + "type": "plain_text", + "text": "My App", + }, + "clear_on_close": False, + "notify_on_close": False, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], +} + +block_suggestion = { + "type": "block_suggestion", + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "container": {"type": "view", "view_id": "V111"}, + "api_app_id": "A111", + "token": "verification_token", + "action_id": "the-id", + "block_id": "block-id", + "value": "search word", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "view": { + "id": "V111", + "team_id": "T111", + "type": "modal", + "blocks": [ + { + "type": "input", + "block_id": "5ar+", + "label": {"type": "plain_text", "text": "Label"}, + "optional": False, + "element": {"type": "plain_text_input", "action_id": "i5IpR"}, + }, + { + "type": "input", + "block_id": "es_b", + "label": {"type": "plain_text", "text": "Search"}, + "optional": False, + "element": { + "type": "external_select", + "action_id": "es_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + }, + }, + { + "type": "input", + "block_id": "mes_b", + "label": {"type": "plain_text", "text": "Search (multi)"}, + "optional": False, + "element": { + "type": "multi_external_select", + "action_id": "mes_a", + "placeholder": {"type": "plain_text", "text": "Select an item"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "view-id", + "state": {"values": {}}, + "hash": "111.xxx", + "title": {"type": "plain_text", "text": "My App"}, + "clear_on_close": False, + "notify_on_close": False, + "close": {"type": "plain_text", "text": "Cancel"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "previous_view_id": None, + "root_view_id": "V111", + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, +} + +dialog_suggestion = { + "type": "dialog_suggestion", + "token": "verification_token", + "action_ts": "1596603332.676855", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Sandbox Org", + }, + "user": {"id": "W111", "name": "primary-owner", "team_id": "T111"}, + "channel": {"id": "C111", "name": "test-channel"}, + "name": "types", + "value": "search keyword", + "callback_id": "the-id", + "state": "Limo", +} diff --git a/tests/slack_bolt_async/middleware/__init__.py b/tests/slack_bolt_async/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/__init__.py b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/__init__.py new file mode 100644 index 000000000..e69de29bb 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/authorization/__init__.py b/tests/slack_bolt_async/middleware/authorization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py new file mode 100644 index 000000000..0ddb6281d --- /dev/null +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -0,0 +1,83 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.middleware.authorization.async_single_team_authorization import ( + AsyncSingleTeamAuthorization, +) +from slack_bolt.middleware.authorization.internals import _build_user_facing_authorize_error_message +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from tests.mock_web_api_server import ( + 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 next(): + return BoltResponse(status=200) + + +class TestSingleTeamAuthorization: + + @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: + self.mock_api_server_base_url = "http://localhost:8888" + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_success_pattern(self): + authorization = AsyncSingleTeamAuthorization() + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url, token="xoxb-valid") + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + + @pytest.mark.asyncio + async def test_success_pattern_with_bot_scopes(self): + client = AsyncWebClient(base_url=self.mock_api_server_base_url, token="xoxb-valid") + authorization = AsyncSingleTeamAuthorization() + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = client + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + assert req.context.authorize_result.bot_scopes == ["chat:write", "commands"] + assert req.context.authorize_result.user_scopes is None + + @pytest.mark.asyncio + async def test_failure_pattern(self): + authorization = AsyncSingleTeamAuthorization() + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url, token="dummy") + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == _build_user_facing_authorize_error_message() + + @pytest.mark.asyncio + async def test_failure_pattern_custom_message(self): + authorization = AsyncSingleTeamAuthorization(user_facing_authorize_error_message="foo") + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = AsyncWebClient(base_url=self.mock_api_server_base_url, token="dummy") + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "foo" diff --git a/tests/slack_bolt_async/middleware/request_verification/__init__.py b/tests/slack_bolt_async/middleware/request_verification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py new file mode 100644 index 000000000..126b04f94 --- /dev/null +++ b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py @@ -0,0 +1,88 @@ +from time import time + +import pytest +from slack_sdk.signature import SignatureVerifier + +from slack_bolt.middleware.request_verification.async_request_verification import ( + AsyncRequestVerification, +) +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +async def next(): + return BoltResponse(status=200, body="next") + + +class TestAsyncRequestVerification: + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + @pytest.mark.asyncio + async def test_valid(self): + middleware = AsyncRequestVerification(signing_secret="secret") + timestamp = str(int(time())) + raw_body = "payload={}" + req = AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + resp = BoltResponse(status=404) + resp = await middleware.async_process(req=req, resp=resp, next=next) + assert resp.status == 200 + assert resp.body == "next" + + @pytest.mark.asyncio + async def test_invalid(self): + middleware = AsyncRequestVerification(signing_secret="secret") + req = AsyncBoltRequest(body="payload={}", headers={}) + resp = BoltResponse(status=404) + resp = await middleware.async_process(req=req, resp=resp, next=next) + assert resp.status == 401 + assert resp.body == """{"error": "invalid request"}""" + + @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) diff --git a/tests/slack_bolt_async/oauth/__init__.py b/tests/slack_bolt_async/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py new file mode 100644 index 000000000..5714e1a6a --- /dev/null +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -0,0 +1,337 @@ +import json +from time import time +from urllib.parse import quote + +import pytest +from tests.utils import remove_os_env_temporarily, restore_os_env +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_callback_options import ( + AsyncFailureArgs, + AsyncSuccessArgs, + AsyncCallbackOptions, +) +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, +) + + +class TestAsyncOAuthFlow: + + @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: + self.mock_api_server_base_url = "http://localhost:8888" + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) + + def next(self): + pass + + @pytest.mark.asyncio + async def test_instantiation(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + user_scopes=["search:read"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None + + @pytest.mark.asyncio + async def test_scopes_as_str(self): + settings = AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + user_scopes="search:read", + ) + assert settings.scopes == ["chat:write", "commands"] + assert settings.user_scopes == ["search:read"] + + @pytest.mark.asyncio + async def test_instantiation_non_async_settings(self): + with pytest.raises(BoltError): + AsyncOAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + ) + ) + + @pytest.mark.asyncio + async def test_instantiation_non_async_settings_to_app(self): + with pytest.raises(BoltError): + AsyncApp( + signing_secret="xxx", + oauth_settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes="chat:write,commands", + ), + ) + + @pytest.mark.asyncio + async def test_handle_installation_default(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + assert resp.headers.get("set-cookie") is not None + + @pytest.mark.asyncio + async def test_handle_installation_no_rendering(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert resp.headers.get("set-cookie") is not None + + @pytest.mark.asyncio + async def test_handle_installation_team_param(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = AsyncBoltRequest(body="", query={"team": "T12345"}) + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert "&team=T12345" in location_header + assert resp.headers.get("set-cookie") is not None + + @pytest.mark.asyncio + async def test_handle_installation_no_state_validation(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + install_page_rendering_enabled=False, # disabled + state_validation_enabled=False, # disabled + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 302 + location_header = resp.headers.get("location")[0] + assert "https://slack.com/oauth/v2/authorize?state=" in location_header + assert resp.headers.get("set-cookie") is None + + @pytest.mark.asyncio + async def test_handle_callback(self): + oauth_flow = AsyncOAuthFlow( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + app = AsyncApp(signing_secret="signing_secret", oauth_flow=oauth_flow) + global_shortcut_body = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + body = f"payload={quote(json.dumps(global_shortcut_body))}" + timestamp = str(int(time())) + signature_verifier = SignatureVerifier("signing_secret") + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [signature_verifier.generate_signature(body=body, timestamp=timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_handle_callback_invalid_state(self): + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_handle_callback_invalid_state(self): + class MyOAuthStateStore(AsyncOAuthStateStore): + async def async_issue(self, *args, **kwargs) -> str: + return "expired_one" + + async def async_consume(self, state: str) -> bool: + return False + + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=MyOAuthStateStore(), + ) + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_handle_callback_no_state_validation(self): + oauth_flow = AsyncOAuthFlow( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_validation_enabled=False, # disabled + state_store=None, + ), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + + @pytest.mark.asyncio + async def test_handle_callback_using_options(self): + async def success(args: AsyncSuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + async def failure(args: AsyncFailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = AsyncOAuthFlow( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + callback_options=AsyncCallbackOptions( + success=success, + failure=failure, + ), + ), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py new file mode 100644 index 000000000..00300929f --- /dev/null +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -0,0 +1,135 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.oauth.async_callback_options import ( + AsyncFailureArgs, + AsyncSuccessArgs, + AsyncCallbackOptions, +) +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) + + +class TestAsyncOAuthFlowSQLite3: + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + setup_mock_web_api_server_async(self) + try: + self.mock_api_server_base_url = "http://localhost:8888" + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + + def next(self): + pass + + @pytest.mark.asyncio + async def test_instantiation(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + assert oauth_flow is not None + assert oauth_flow.logger is not None + assert oauth_flow.client is not None + + @pytest.mark.asyncio + async def test_handle_installation(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + req = AsyncBoltRequest(body="") + resp = await oauth_flow.handle_installation(req) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body + + @pytest.mark.asyncio + async def test_handle_callback(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + success_url="https://www.example.com/completion", + failure_url="https://www.example.com/failure", + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert "https://www.example.com/completion" in resp.body + + @pytest.mark.asyncio + async def test_handle_callback_invalid_state(self): + oauth_flow = AsyncOAuthFlow.sqlite3( + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 400 + + @pytest.mark.asyncio + async def test_handle_callback_using_options(self): + async def success(args: AsyncSuccessArgs) -> BoltResponse: + assert args.request is not None + return BoltResponse(status=200, body="customized") + + async def failure(args: AsyncFailureArgs) -> BoltResponse: + assert args.request is not None + assert args.reason is not None + return BoltResponse(status=502, body="customized") + + oauth_flow = AsyncOAuthFlow.sqlite3( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + database="./logs/test_db", + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + callback_options=AsyncCallbackOptions( + success=success, + failure=failure, + ), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + assert resp.body == "customized" + + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 502 + assert resp.body == "customized" diff --git a/tests/slack_bolt_async/request/__init__.py b/tests/slack_bolt_async/request/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/request/test_async_request.py b/tests/slack_bolt_async/request/test_async_request.py new file mode 100644 index 000000000..b33c4eb36 --- /dev/null +++ b/tests/slack_bolt_async/request/test_async_request.py @@ -0,0 +1,19 @@ +import pytest + +from slack_bolt.request.async_request import AsyncBoltRequest + + +class TestAsyncRequest: + @pytest.mark.asyncio + async def test_all_none_values_http(self): + req = AsyncBoltRequest(body=None, headers=None, query=None, context=None) + assert req is not None + assert req.raw_body == "" + assert req.body == {} + + @pytest.mark.asyncio + async def test_all_none_values_socket_mode(self): + req = AsyncBoltRequest(body=None, headers=None, query=None, context=None, mode="socket_mode") + assert req is not None + assert req.raw_body == "" + assert req.body == {} diff --git a/tests/slack_bolt_async/workflows/__init__.py b/tests/slack_bolt_async/workflows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/workflows/step/__init__.py b/tests/slack_bolt_async/workflows/step/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/slack_bolt_async/workflows/step/test_async_step.py b/tests/slack_bolt_async/workflows/step/test_async_step.py new file mode 100644 index 000000000..f377d8906 --- /dev/null +++ b/tests/slack_bolt_async/workflows/step/test_async_step.py @@ -0,0 +1,39 @@ +import pytest + +from slack_bolt import Ack +from slack_bolt.error import BoltError +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep + + +class TestStep: + def test_build(self): + step = AsyncWorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.execute(just_ack) + assert step.build() is not None + + def test_build_errors(self): + with pytest.raises(BoltError): + step = AsyncWorkflowStep.builder("foo") + step.save(just_ack) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = AsyncWorkflowStep.builder("foo") + step.edit(just_ack) + step.execute(just_ack) + step.build() + with pytest.raises(BoltError): + step = AsyncWorkflowStep.builder("foo") + step.edit(just_ack) + step.save(just_ack) + step.build() + + +def just_ack(ack: Ack): + ack() + + +def execute(): + pass diff --git a/tests/utils.py b/tests/utils.py index 185e41b42..e06d0f861 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,9 +1,13 @@ import os +import asyncio def remove_os_env_temporarily() -> dict: old_env = os.environ.copy() os.environ.clear() + for key, value in old_env.items(): + if key.startswith("BOLT_PYTHON_"): + os.environ[key] = value return old_env