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/.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 8e95720eb..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' diff --git a/.github/ISSUE_TEMPLATE/02_enhancement.md b/.github/ISSUE_TEMPLATE/02_enhancement.md index 0556ff91f..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' diff --git a/.github/ISSUE_TEMPLATE/03_document.md b/.github/ISSUE_TEMPLATE/03_document.md index fbd07e438..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,7 +10,7 @@ assignees: '' ### The page URLs -* https://slack.dev/bolt-python/ +* https://docs.slack.dev/tools/bolt-python/ ## Requirements diff --git a/.github/ISSUE_TEMPLATE/04_bug.md b/.github/ISSUE_TEMPLATE/04_bug.md index e5191ee02..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' 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 6839007d8..f8edeeabd 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,26 +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 -``` - -### Additional settings for pip - -pip 20.2 introduced a new flag to test the upcoming change: https://discuss.python.org/t/announcement-pip-20-2-release/4863/2 -Turn on the feature on your local machine for testing it. Just running the following command helps you turn it on. - -```bash -pip config set global.use-feature 2020-resolver -``` - -The following file should be generated. - -```yaml -# ~/.config/pip/pip.conf -[global] -use-feature = 2020-resolver +```sh +python -m venv env_3.8.5 +source env_3.8.5/bin/activate ``` ## Tasks @@ -66,29 +49,30 @@ If you make some changes to this SDK, please write corresponding unit tests as m If this is your first time to run tests, although it may take a bit long time, running the following script is the easiest. -```bash -$ ./scripts/install_all_and_run_tests.sh +```sh +./scripts/install_all_and_run_tests.sh ``` Once you installed all the required dependencies, you can use the following one. -```bash -$ ./scripts/run_tests.sh +```sh +./scripts/run_tests.sh ``` Also, you can run a single test this way. -```bash -$ ./scripts/run_tests.sh tests/scenario_tests/test_app.py +```sh +./scripts/run_tests.sh tests/scenario_tests/test_app.py ``` #### Run the Samples If you make changes to `slack_bolt/adapter/*`, please verify if it surely works by running the apps under `examples` directory. -```bash +```sh # Install all optional dependencies -$ pip install -e ".[adapter]" +$ pip install -r requirements/adapter.txt +$ pip install -r requirements/adapter_testing.txt # Set required env variables $ export SLACK_SIGNING_SECRET=*** @@ -107,83 +91,133 @@ $ FLASK_APP=app.py FLASK_ENV=development flask run -p 3000 $ 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 @@ -212,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 6ae896f2b..4dcfd152a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,12 @@ -(Describe the goal of this PR. Mention any related Issue numbers) +## Summary -### Category (place an `x` in each of the `[ ]`) + + +### Testing + + + +### Category * [ ] `slack_bolt.App` and/or its core components * [ ] `slack_bolt.async_app.AsyncApp` and/or its core components @@ -8,7 +14,7 @@ * [ ] Document pages under `/docs` * [ ] Others -## Requirements (place an `x` in each `[ ]`) +## Requirements Please read the [Contributing guidelines](https://github.com/slackapi/bolt-python/blob/main/.github/contributing.md) and [Code of Conduct](https://slackhq.github.io/code-of-conduct) before creating this issue or pull request. By submitting, you are agreeing to those rules. 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..6d504ea83 --- /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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.LATEST_SUPPORTED_PY }} + - name: Install synchronous dependencies + run: | + pip install -U pip + pip install -U . + pip install -r requirements/tools.txt + - name: Type check synchronous modules + run: mypy --config-file pyproject.toml --exclude "async_|/adapter/" + - name: Install async and adapter dependencies + run: | + pip install -r requirements/async.txt + pip install -r requirements/adapter.txt + - name: Type check all modules + run: mypy --config-file pyproject.toml + + unittest: + name: Unit tests + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install synchronous dependencies + run: | + pip install -U pip + pip install . + pip install -r requirements/testing_without_asyncio.txt + - name: Run tests without aiohttp + run: | + pytest tests/slack_bolt/ --junitxml=reports/test_slack_bolt.xml + pytest tests/scenario_tests/ --junitxml=reports/test_scenario.xml + - name: Install adapter dependencies + run: | + pip install -r requirements/adapter.txt + pip install -r requirements/adapter_testing.txt + - 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.txt + - name: Run tests for Socket Mode adapters + run: | + # Requires async test dependencies + pytest tests/adapter_tests/socket_mode/ --junitxml=reports/test_adapter_socket_mode.xml + - name: Install all dependencies + run: | + pip install -r requirements/testing.txt + - 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@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + directory: ./reports/ + fail_ci_if_error: true + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.LATEST_SUPPORTED_PY }} + - name: Install dependencies + run: | + pip install -U pip + pip install . + pip install -r requirements/adapter.txt + pip install -r requirements/testing.txt + pip install -r requirements/adapter_testing.txt + - name: Run all tests for codecov + run: | + pytest --cov=./slack_bolt/ --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.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@af78098f536edbc4de71162a307590698245be95 # v3.0.1 + 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..9666057aa --- /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@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v3.0.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..dfc224c83 --- /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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.release.tag_name || github.ref }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.x" + + - name: Build release distributions + run: | + scripts/build_pypi_package.sh + + - name: Persist dist folder + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: release-dist + path: dist/ + + test-pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + # Run this job for workflow_dispatch events when dry_run input is not 'true' + # Note: The comparison is against a string value 'true' since GitHub Actions inputs are strings + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' + environment: + name: testpypi + permissions: + id-token: write + + steps: + - name: Retrieve dist folder + uses: actions/download-artifact@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@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + repository-url: https://test.pypi.org/legacy/ + + pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + if: github.event_name == 'release' + environment: + name: pypi + permissions: + id-token: write + + steps: + - name: Retrieve dist folder + uses: actions/download-artifact@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@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml new file mode 100644 index 000000000..c29bface2 --- /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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.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 693347598..000000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: - python -python: - - "3.6" - - "3.7" - - "3.8" -install: - - python setup.py install - - pip install -U pip - # https://discuss.python.org/t/announcement-pip-20-2-release/4863 - - pip config set global.use-feature 2020-resolver - - pip install "pytest>=5,<6" "pytest-cov>=2,<3" -script: - # testing without aiohttp - - travis_retry pytest tests/slack_bolt/ - - travis_retry pytest tests/scenario_tests/ - # testing for adapters - - pip install -e ".[adapter]" - - travis_retry pytest tests/adapter_tests/ - # testing with aiohttp - - pip install -e ".[async]" - - pip install "pytest-asyncio<1" - - travis_retry pytest tests/slack_bolt_async/ - - travis_retry pytest tests/scenario_tests_async/ - - travis_retry pytest tests/adapter_tests_async/ - # run all tests just in case - - travis_retry python setup.py test - # Run pytype only for Python 3.8 - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pip install "pytype" && pytype slack_bolt/; fi - - if [ ${TRAVIS_PYTHON_VERSION:0:3} == "3.8" ]; then pytest --cov=slack_bolt/ && bash <(curl -s https://codecov.io/bash); fi diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..892a858e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,246 @@ +# 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.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.txt` -- async runtime deps (`aiohttp`, `websockets`) +- `adapter.txt` -- all framework adapter deps (Flask, Django, FastAPI, etc.) +- `testing.txt` -- test runner deps (`pytest`, `pytest-asyncio`, includes `async.txt`) +- `testing_without_asyncio.txt` -- test deps without async (`pytest`, `pytest-cov`) +- `adapter_testing.txt` -- adapter-specific test deps (`moto`, `boddle`, `sanic-testing`) +- `tools.txt` -- dev tools (`mypy`, `flake8`, `black`) + +When adding a new dependency: add it to the appropriate `requirements/*.txt` file with version constraints, never to `pyproject.toml` `dependencies` (unless it's a core runtime dep, which is very rare). + +## Test Organization 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 b64218451..39747df40 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,25 @@ -# Bolt ![Bolt logo](docs/assets/bolt-logo.svg) for Python (beta) - -[![Python Version][python-version]][pypi-url] -[![pypi package][pypi-image]][pypi-url] -[![Build Status][travis-image]][travis-url] -[![Codecov][codecov-image]][codecov-url] - -A Python framework to build Slack apps in a flash with the latest platform features. Read the [getting started guide](https://slack.dev/bolt-python/tutorial/getting-started) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. +

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 3.6+ required +# Python 3.7+ required python -m venv .venv source .venv/bin/activate @@ -49,34 +58,69 @@ python app.py ngrok http 3000 ``` -## Listening for events -Apps typically react to a collection of incoming events, which can correspond to [Events API events](https://api.slack.com/events-api), [actions](https://api.slack.com/interactivity/components), [shortcuts](https://api.slack.com/interactivity/shortcuts), [slash commands](https://api.slack.com/interactivity/slash-commands) or [options requests](https://api.slack.com/reference/block-kit/block-elements#external_select). For each type of -request, there's a method to build a listener function. +## 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 -# Listen for an event from the Events API -app.event(event_type, fn) +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler -# Convenience method to listen to only `message` events using a string or re.Pattern -app.message([pattern ,] fn) +# 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) +app.action(action_id)(fn) # Listen for dialog submissions -app.action({"callback_id": callbackId}, fn) - -# Listen for a global or message shortcuts -app.shortcut(callback_id, fn) +app.action({"callback_id": callbackId})(fn) # Listen for slash commands -app.command(command_name, fn) +app.command(command_name)(fn) -# Listen for view_submission modal events -app.view(callback_id, 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) +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: @@ -89,24 +133,27 @@ def handle_event(event): ## Making things happen -Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments. +Most of the app's functionality will be inside listener functions (the `fn` parameters above). These functions are called with a set of arguments, 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 | | :---: | :--- | -| `payload` | Contents of the incoming event. The payload structure depends on the listener. For example, for an Events API event, `payload` will be the [event type structure](https://api.slack.com/events-api#event_type_structure). For a block action, it will be the action from within the `actions` list. The `payload` dictionary is also accessible via the alias corresponding to the listener (`message`, `event`, `action`, `shortcut`, `view`, `command`, or `options`). For example, if you were building a `message()` listener, you could use the `payload` and `message` arguments interchangably. **An easy way to understand what's in a payload is to log it**. | -| `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). -| `ack` | Function that **must** be called to acknowledge that your app received the incoming event. `ack` exists for all actions, shortcuts, view submissions, slash command and options requests. `ack` returns a promise that resolves when complete. Read more in [Acknowledging events](https://slack.dev/bolt-python/concepts#acknowledge). -| `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://slack.dev/bolt-python/concepts#authenticating-oauth), or manually using the `authorize` function. -| `respond` | Utility function that responds to incoming events **if** it contains a `response_url` (shortcuts, actions, and slash commands). +| `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. -| `body` | Dictionary that contains the entire body of the request (superset of `payload`). Some accessory data is only available outside of the payload (such as `trigger_id` and `authed_users`). +| `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.6+ required +# Python 3.7+ required python -m venv .venv source .venv/bin/activate @@ -141,11 +188,11 @@ If you want to use another async Web framework (e.g., Sanic, FastAPI, Starlette) * [The Bolt app examples](https://github.com/slackapi/bolt-python/tree/main/examples) * [The built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) -Apps can be run the same way as the syncronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [sample code](https://github.com/slackapi/bolt-python/tree/main/samples). +Apps can be run the same way as the syncronous example above. If you'd prefer another async Web framework (e.g., Sanic, FastAPI, Starlette), take a look at [the built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter) and their corresponding [examples](https://github.com/slackapi/bolt-python/tree/main/examples). ## Getting Help -[The documentation](https://slack.dev/bolt-python) has more information on basic and advanced concepts for Bolt for Python. +[The documentation](https://docs.slack.dev/tools/bolt-python/) has more information on basic and advanced concepts for Bolt for Python. Also, all the Python module documents of this library are available [here](https://docs.slack.dev/tools/bolt-python/reference/). If you otherwise get stuck, we're here to help. The following are the best ways to get assistance working through your issue: @@ -155,8 +202,6 @@ If you otherwise get stuck, we're here to help. The following are the best ways [pypi-image]: https://badge.fury.io/py/slack-bolt.svg [pypi-url]: https://pypi.org/project/slack-bolt/ -[travis-image]: https://travis-ci.org/slackapi/bolt-python.svg?branch=main -[travis-url]: https://travis-ci.org/slackapi/bolt-python [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/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/.gitignore b/docs/.gitignore deleted file mode 100644 index c48a717ef..000000000 --- a/docs/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -_site -Gemfile.lock -.env -.jekyll-metadata -.vscode/ -.bundle/ -vendor/ diff --git a/docs/Gemfile b/docs/Gemfile deleted file mode 100644 index bcdec967b..000000000 --- a/docs/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source 'https://rubygems.org' -gem 'github-pages', group: :jekyll_plugins -gem 'dotenv' diff --git a/docs/_advanced/adapters.md b/docs/_advanced/adapters.md deleted file mode 100644 index 8ff882898..000000000 --- a/docs/_advanced/adapters.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Adapters -lang: en -slug: adapters -order: 0 ---- - -
-Adapters are responsible for handling and parsing incoming events from Slack to conform to `BoltRequest`, then dispatching those events to your Bolt app. - -By default, Bolt will use the built-in `HTTPSever` adapter. While this is okay for local development, it is not recommended for production. Bolt for Python includes a collection of built-in adapters that can be imported and used with your app. The built-in adapters support a variety of popular Python frameworks including Flask, Django, and Starlette among others. Adapters support the use of any production-ready web server of your choice. - -To use an adapter, you'll create an app with the framework of your choosing and import its corresponding adapter. Then you'll initialize the adapter instance and call its function that handles and parses incoming events. - -The full list adapters, as well as configuration and sample usage, can be found within the repository's `examples` folder. -
- -```python -from slack_bolt import App -app = App( - signing_secret=os.environ.get("SIGNING_SECRET"), - token=os.environ.get("SLACK_BOT_TOKEN") -) - -# There is nothing specific to Flask here! -# App is completely framework/runtime agnostic -@app.command("/hello-bolt") -def hello(body, ack): - ack(f"Hi <@{body['user_id']}>!") - -# Initialize Flask app -from flask import Flask, request -flask_app = Flask(__name__) - -# SlackRequestHandler translates WSGI requests to Bolt's interface -# and builds WSGI response from Bolt's response. -from slack_bolt.adapter.flask import SlackRequestHandler -handler = SlackRequestHandler(app) - -# Register routes to Flask app -@flask_app.route("/slack/events", methods=["POST"]) -def slack_events(): - # handler runs App's dispatch method - return handler.handle(request) -``` diff --git a/docs/_advanced/lazy_listener.md b/docs/_advanced/lazy_listener.md deleted file mode 100644 index c201743a2..000000000 --- a/docs/_advanced/lazy_listener.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Lazy listeners (FaaS) -lang: en -slug: lazy-listeners -order: 9 ---- - -
-โš ๏ธ Lazy listener functions are a beta feature to make it easier to deploy Bolt for Python apps to FaaS environments. As the feature is developed, Bolt for Python's API is subject to change. - -Typically you'd call `ack()` as the first step of your listener functions. Calling `ack()` tells Slack that you've received the event and are handling it in within reasonable amount of time (3 seconds). - -However, apps running on FaaS or similar runtimes that don't allow you to run threads or processes after returning an HTTP response cannot follow this pattern. Instead, you should set the `process_before_response` flag to `True`. This allows you to create a listener that calls `ack()` and handles the event safely, though you still need to complete everything within 3 seconds. For events, while a listener doesn't need `ack()` method call as you normally would, the listener needs to complete within 3 seconds, too. - -Lazy listeners can be a solution for this issue. Rather than acting as a decorator, lazy listeners take two keyword args: -* `ack: Callable`: Responsible for calling `ack()` -* `lazy: List[Callable]`: Responsible for handling any time-consuming processes related to the event. The lazy function does not have access to `ack()`. -
- -```python -def respond_to_slack_within_3_seconds(body, ack): - if "text" in body: - ack(":x: Usage: /start-process (description here)") - else: - ack(f"Accepted! (task: {body['text']})") - -import time -def run_long_process(respond, body): - time.sleep(5) # longer than 3 seconds - respond(f"Completed! (task: {body['text']})") - -app.command("/start-process")( - # ack() is still called within 3 seconds - ack=respond_to_slack_within_3_seconds, - # Lazy function is responsible for processing the event - lazy=[run_long_process] -) -``` - -
- -

Example with AWS Lambda

-
- -
-This example deploys the code to [AWS Lambda](https://aws.amazon.com/lambda/). There are more examples within the [`sample` folder](https://github.com/slackapi/bolt-python/tree/main/adapter). - -```bash -pip install slack_bolt -# Save the source code as main.py -# and refer handler as `handler: main.handler` in config.yaml - -# https://pypi.org/project/python-lambda/ -pip install python-lambda - -# Configure config.yml properly (AWSLambdaFullAccess required) -export SLACK_SIGNING_SECRET=*** -export SLACK_BOT_TOKEN=xoxb-*** -echo 'slack_bolt' > requirements.txt -lambda deploy --config-file config.yaml --requirements requirements.txt -``` -
- -```python -from slack_bolt import App -# process_before_response must be True when running on FaaS -app = App(process_before_response=True) - -def respond_to_slack_within_3_seconds(body, ack): - if "text" in body: - ack(":x: Usage: /start-process (description here)") - else: - ack(f"Accepted! (task: {body['text']})") - -import time -def run_long_process(respond, body): - time.sleep(5) # longer than 3 seconds - respond(f"Completed! (task: {body['text']})") - -app.command("/start-process")( - ack=respond_to_slack_within_3_seconds, # responsible for calling `ack()` - lazy=[run_long_process] # unable to call `ack()` / can have multiple functions -) - -from slack_bolt.adapter.aws_lambda import SlackRequestHandler -def handler(event, context): - slack_handler = SlackRequestHandler(app=app) - return slack_handler.handle(event, context) -``` -
diff --git a/docs/_advanced/listener_middleware.md b/docs/_advanced/listener_middleware.md deleted file mode 100644 index 73c3d3590..000000000 --- a/docs/_advanced/listener_middleware.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Listener middleware -lang: en -slug: listener-middleware -order: 5 ---- - -
-Listener middleware is only run for the listener in which it's passed. You can pass any number of middleware functions to the listener using the `middleware` parameter, which must be a list that contains one to many middleware functions. -
- -```python -# Listener middleware which filters out messages with "bot_message" subtype -def no_bot_messages(message, next): - subtype = message.get("subtype") - if subtype != "bot_message": - next() - -# This listener only receives messages from humans -@app.event(event="message", middleware=[no_bot_messages]) -def log_message(logger, event): - logger.info(f"(MSG) User: {event['user']}\nMessage: {event['text']}") -``` diff --git a/docs/_basic/acknowledging_events.md b/docs/_basic/acknowledging_events.md deleted file mode 100644 index b341ca673..000000000 --- a/docs/_basic/acknowledging_events.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Acknowledging events -lang: en -slug: acknowledge -order: 7 ---- - -
- -Actions, commands, and options events must **always** be acknowledged using the `ack()` function. This lets Slack know that the event was received and updates the Slack user interface accordingly. - -Depending on the type of event, your acknowledgement may be different. For example, when acknowledging a menu selection associated with an external data source, you would call `ack()` with a list of relevant [options](https://api.slack.com/reference/block-kit/composition-objects#option). - -We recommend calling `ack()` right away before sending a new message or fetching information from your database since you only have 3 seconds to respond. - -
- -```python -# Example of responding to an external_select options request -@app.options("menu_selection") -def show_menu_options(ack): - options = [ - { - "text": {"type": "plain_text", "text": "Option 1"}, - "value": "1-1", - }, - { - "text": {"type": "plain_text", "text": "Option 2"}, - "value": "1-2", - }, - ] - ack(options=options) -``` diff --git a/docs/_basic/listening_actions.md b/docs/_basic/listening_actions.md deleted file mode 100644 index 131b48701..000000000 --- a/docs/_basic/listening_actions.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Listening to actions -lang: en -slug: action-listening -order: 5 ---- - -
-Your app can listen to user actions, like button clicks, and menu selects, using the `action` method. - -Actions can be filtered on an `action_id` of type `str` or `re.Pattern`. `action_id`s act as unique identifiers for interactive components on the Slack platform. - -Youโ€™ll notice in all `action()` examples, `ack()` is used. It is required to call the `ack()` function within an action listener to acknowledge that the event was received from Slack. This is discussed in the [acknowledging events section](#acknowledge). - -
- -```python -# Your middleware will be called every time an interactive component with the action_id "approve_button" is triggered -@app.action("approve_button") -def update_message(ack): - ack() - # Update the message to reflect the action -``` - -
- -

Listening to actions using a constraint object

-
- -
- -You can use a constraints object to listen to `callback_id`s, `block_id`s, and `action_id`s (or any combination of them). Constraints in the object can be of type `str` or `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"], - ) -``` - -
diff --git a/docs/_basic/listening_events.md b/docs/_basic/listening_events.md deleted file mode 100644 index ddfcd7536..000000000 --- a/docs/_basic/listening_events.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Listening to events -lang: en -slug: event-listening -order: 3 ---- - -
- -You can listen to [any Events API event](https://api.slack.com/events) using the `event()` method after subscribing to it in your app configuration. This allows your app to take action when something happens in 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`. - -
- -```python -# When a user joins the team, send a message in a predefined channel asking them to introduce themselves -@app.event("team_join") -def ask_for_introduction(event, say): - welcome_channel_id = "C12345"; - user_id = event["user"]["id"] - text = f"Welcome to the team, <@{user_id}>! ๐ŸŽ‰ You can introduce yourself in this channel." - say(text=text, channel=welcome_channel_id) -``` - -
- - -

Filtering on message subtypes

-
- -
-The `message()` listener is equivalent to `event("message")`. - -You can filter on subtypes of events by passing in the additional key `subtype`. Common message subtypes like `bot_message` and `message_replied` can be found [on the message event page](https://api.slack.com/events/message#message_subtypes). - -
- -```python -# Matches all messages from bot users -@app.event({ - "type": "message", - "subtype": "message_changed" -}) -def log_message_change(logger, event): - user, text = event["user"], event["text"] - logger.info(f"The user {user} changed the message to {text}") -``` - -
diff --git a/docs/_basic/listening_messages.md b/docs/_basic/listening_messages.md deleted file mode 100644 index 34c30e180..000000000 --- a/docs/_basic/listening_messages.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Listening to messages -lang: en -slug: message-listening -order: 1 ---- - -
- -To listen to messages that [your app has access to receive](https://api.slack.com/messaging/retrieving#permissions), you can use the `message()` method which filters out events that aren't of type `message`. - -`message()` accepts an argument of type `str` or `re.Pattern` object that filters out any messages that donโ€™t match the pattern. - -
- -```python -# This will match any message that contains ๐Ÿ‘‹ -@app.message(":wave:") -def say_hello(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?") -``` - -
diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md deleted file mode 100644 index d8c3803ed..000000000 --- a/docs/_basic/listening_modals.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Listening for view submissions -lang: en -slug: view_submissions -order: 12 ---- - -
- -If a view payload contains any input blocks, you must listen to `view_submission` events to receive their values. To listen to `view_submission` events, you can use the built-in `view()` method. `view()` requires a `callback_id` of type `str` or `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. - -Read more about view submissions in our API documentation. - -
- -```python -# Handle a view_submission event -@app.view("view_b") -def handle_submission(ack, body, client, view): - # Assume there's an input block with `block_1` as the block_id and `input_a` - val = view["state"]["values"]["block_1"]["input_a"] - user = body["user"]["id"] - # Validate the inputs - errors = {} - if val is not None and len(val) <= 5: - errors["block_1"] = "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 - # then sending the user a verification of their submission - - # Message to send user - msg = "" - try: - # Save to DB - msg = f"Your submission of {val} was successful" - except Exception as e: - # Handle error - msg = "There was an error with your submission" - finally: - # Message the user - client.chat_postMessage(channel=user, text=msg) -``` diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md deleted file mode 100644 index 50997ccce..000000000 --- a/docs/_basic/listening_responding_options.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: Listening and responding to options -lang: en -slug: options -order: 14 ---- - -
-The `options()` method listens for incoming option request payloads from Slack. [Similar to `action()`](#action-listening), -an `action_id` or constraints object is required. In order to load external data into your select menus, you must provide an options load URL in your app configuration, appended with `/slack/events`. - -While it's recommended to use `action_id` for `external_select` menus, dialogs do not support Block Kit so you'll have to use the constraints object to filter on a `callback_id`. - -To respond to options requests, you'll need to call `ack()` with a valid `options` or `option_groups` list. Both [external select response examples](https://api.slack.com/reference/messaging/block-elements#external-select) and [dialog response examples](https://api.slack.com/dialogs#dynamic_select_elements_external) can be found on our API site. - -
- -```python -# Example of responding to an external_select options request -@app.options("external_action") -def show_options(ack): - options = [ - { - "text": {"type": "plain_text", "text": "Option 1"}, - "value": "1-1", - }, - { - "text": {"type": "plain_text", "text": "Option 2"}, - "value": "1-2", - }, - ] - ack(options=options) -``` diff --git a/docs/_basic/responding_actions.md b/docs/_basic/responding_actions.md deleted file mode 100644 index dcd83eabf..000000000 --- a/docs/_basic/responding_actions.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Responding to actions -lang: en -slug: action-respond -order: 6 ---- - -
- -There are two main ways to respond to actions. The first (and most common) way is to use `say()`, which sends a message back to the conversation where the incoming event took place. - -The second way to respond to actions is using `respond()`, which is a utility to use the `response_url` associated with the action. - -
- -```python -# Your middleware will be called every time an interactive component with the action_id โ€œapprove_buttonโ€ is triggered -@app.action("approve_button") -def approve_request(ack, say): - # Acknowledge action request - ack(); - say("Request approved ๐Ÿ‘"); -``` - -
- -

Using respond()

-
- -
- -Since `respond()` is a utility for calling the `response_url`, it behaves in the same way. You can pass a JSON object with a new message payload that will be published back to the source of the original interaction with optional properties like `response_type` (which has a value of `in_channel` or `ephemeral`), `replace_original`, and `delete_original`. - -
- -```python -# Listens to actions triggered with action_id of โ€œuser_selectโ€ -@app.action("user_select") -def select_user(ack, action, respond): - ack(); - respond(f"You selected <@{action['selected_user']}>") -``` - -
diff --git a/docs/_basic/sending_messages.md b/docs/_basic/sending_messages.md deleted file mode 100644 index cb6170f6a..000000000 --- a/docs/_basic/sending_messages.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: Sending messages -lang: en -slug: message-sending -order: 2 ---- - -
- -Within your listener function, `say()` is available whenever there is an associated conversation (for example, a conversation where the event or action which triggered the listener occurred). `say()` accepts a string to post simple messages and JSON payloads to send more complex messages. The message payload you pass in will be sent to the associated conversation. - -In the case that youโ€™d like to send a message outside of a listener or you want to do something more advanced (like handle specific errors), you can call `client.chat_postMessage` [using the client attached to your Bolt instance](#web-api). - -
- -```python -# Listens for messages containing "knock knock" and responds with an italicized "who's there?" -@app.message("knock knock") -def ask_who(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](https://api.slack.com/messaging/composing/layouts) and look through templates of common app flows [in the Block Kit Builder](https://api.slack.com/tools/block-kit-builder?template=1). - -
- -```python -# Sends a section block with datepicker when someone reacts with a ๐Ÿ“… emoji -@app.event("reaction_added") -def show_datepicker(event, say): - reaction = event["reaction"] - if reaction == "calendar": - blocks = [{ - "type": "section", - "text": {"type": "mrkdwn", "text": "Pick a date for me to remind you"}, - "accessory": { - "type": "datepicker", - "action_id": "datepicker_remind", - "initial_date": "2020-05-04", - "placeholder": {"type": "plain_text", "text": "Select a date"} - } - }] - say( - blocks=blocks, - text="Pick a date for me to remind you" - ) -``` - -
diff --git a/docs/_basic/web_api.md b/docs/_basic/web_api.md deleted file mode 100644 index e29812106..000000000 --- a/docs/_basic/web_api.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Using the Web API -lang: en -slug: web-api -order: 4 ---- - -
-You can call [any Web API method](https://api.slack.com/methods) using the [`WebClient`](https://slack.dev/python-slack-sdk/basic_usage.html) provided to your Bolt app as `app.client` (given that your app has the appropriate scopes). When you call one the clientโ€™s methods, it returns a `SlackResponse` which contains the response from Slack. - -The token used to initialize Bolt can be found in the `context` object, which is required to call most Web API methods. - -
- -```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/_config.yml b/docs/_config.yml deleted file mode 100644 index 716bea204..000000000 --- a/docs/_config.yml +++ /dev/null @@ -1,68 +0,0 @@ -# For technical reasons, this file is *NOT* reloaded automatically when you use -# 'bundle exec jekyll serve'. If you change this file, please restart the server process. -title: Bolt -description: >- - A framework that makes Slack app development fast and straight-forward. - With a single interface for Slackโ€™s Web API, Events API, and interactive features, - Bolt gives you the full power of the Slack platform out of the box. -baseurl: /bolt-python -url: https://slack.dev - -collections: - basic: - output: false - steps: - output: false - advanced: - output: false - tutorials: - output: true - permalink: /tutorials/:slug - -defaults: - - - scope: - path: "" - values: - layout: "default" - -# Translation strings used in templates - they are typically used using t[page.lang] -# so it's important to have corresponding strings for each translated language -t: - en: - basic: Basic concepts - steps: Workflow steps - advanced: Advanced concepts - start: Getting started - contribute: Contributing - ja-jp: - basic: ๅŸบๆœฌ็š„ใชๆฆ‚ๅฟต - # TODO: translate this title - steps: Workflow steps - advanced: ๅฟœ็”จใ‚ณใƒณใ‚ปใƒ—ใƒˆ - start: Bolt ๅ…ฅ้–€ใ‚ฌใ‚คใƒ‰ - contribute: ่ฒข็Œฎ - -# Metadata -repo_name: bolt-python -github_username: SlackAPI - -code_of_conduct_url: https://slackhq.github.io/code-of-conduct -cla_url: https://cla-assistant.io/slackapi/bolt-python - -google_analytics: UA-56978219-13 -google_tag_manager: GTM-KFZ5MK7 - -# Build settings -markdown: kramdown -kramdown: - parse_block_html: true - parse_span_html: true - syntax_highlighter_opts: - block: - line_numbers: true -plugins: - - jemoji - - jekyll-redirect-from - -repository: slackapi/bolt-python diff --git a/docs/_includes/analytics.html b/docs/_includes/analytics.html deleted file mode 100644 index ec5f3a8a2..000000000 --- a/docs/_includes/analytics.html +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/docs/_includes/head.html b/docs/_includes/head.html deleted file mode 100644 index e55079ed9..000000000 --- a/docs/_includes/head.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - Slack | Bolt for Python - - - - - - {% if page.lang == "ja-jp" %} - - {% endif %} - - - - - - - - - - - diff --git a/docs/_includes/header.html b/docs/_includes/header.html deleted file mode 100644 index 55924b2d4..000000000 --- a/docs/_includes/header.html +++ /dev/null @@ -1,12 +0,0 @@ -
-
- Code on GitHub - Slack Platform Home - - {% if page.lang == "ja-jp" %} - English - {% else %} - - {% endif %} -
-
diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html deleted file mode 100644 index ecadae7b0..000000000 --- a/docs/_includes/sidebar.html +++ /dev/null @@ -1,78 +0,0 @@ - \ No newline at end of file diff --git a/docs/_includes/tag_manager.html b/docs/_includes/tag_manager.html deleted file mode 100644 index 9ffc5e093..000000000 --- a/docs/_includes/tag_manager.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html deleted file mode 100644 index f929bd989..000000000 --- a/docs/_layouts/default.html +++ /dev/null @@ -1,64 +0,0 @@ ---- -sidebar_style: main ---- - - - -{% include head.html %} - - - {% include tag_manager.html %} -
-
- {% include sidebar.html %} -
- - -
-
- {% include header.html %} -
- -
- {% assign basic_sections = site.basic | sort: "order" | where: "lang", page.lang %} - {% for section in basic_sections %} -
-

{{ section.title }}

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

{{ section.title }}

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

{{ section.title }}

- - {{ section.content | markdownify }} - -
-
- {% endfor %} -
-
- -
- {% include analytics.html %} - - - \ No newline at end of file diff --git a/docs/_layouts/tutorial.html b/docs/_layouts/tutorial.html deleted file mode 100644 index e54aa628b..000000000 --- a/docs/_layouts/tutorial.html +++ /dev/null @@ -1,36 +0,0 @@ ---- -sidebar_style: main ---- - - - -{% include head.html %} - - - {% include tag_manager.html %} -
-
- {% include sidebar.html %} -
- - -
-
- {% include header.html %} -
- -
-
    -
    - -
    - {{ content | markdownify }} -
    -
    - -
    - - - {% include analytics.html %} - - diff --git a/docs/_steps/adding_editing_workflow_step.md b/docs/_steps/adding_editing_workflow_step.md deleted file mode 100644 index 55e040d62..000000000 --- a/docs/_steps/adding_editing_workflow_step.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Adding or editing workflow steps -lang: en -slug: adding-editing-steps -order: 3 ---- - -
    - -When a builder adds (or later edits) your step in their workflow, your app will receive a [`workflow_step_edit` event](https://api.slack.com/reference/workflows/workflow_step_edit). The `edit` callback in your `WorkflowStep` configuration will be run when this event is received. - -Whether a builder is adding or editing a step, you need to send them a [workflow step configuration modal](https://api.slack.com/reference/workflows/configuration-view). This modal is where step-specific settings are chosen, and it has more restrictions than typical modalsโ€”most notably, it cannot include `titleโ€‹`, `submitโ€‹`, or `close`โ€‹ properties. By default, the configuration modal's `callback_id` will be the same as the workflow step. - -Within the `edit` callback, the `configure()` utility can be used to easily open your step's configuration modal by passing in the view's blocks with the corresponding `blocks` argument. To disable saving the configuration before certain conditions are met, you can also pass in `submit_disabled` with a value of `True`. - -To learn more about opening configuration modals, [read the documentation](https://api.slack.com/workflows/steps#handle_config_view). - -
    - -```python -def edit(ack, step, configure): - ack() - - blocks = [ - { - "type": "input", - "block_id": "task_name_input", - "element": { - "type": "plain_text_input", - "action_id": "name", - "placeholder": {"type": "plain_text", "text": "Add a task name"}, - }, - "label": {"type": "plain_text", "text": "Task name"}, - }, - { - "type": "input", - "block_id": "task_description_input", - "element": { - "type": "plain_text_input", - "action_id": "description", - "placeholder": {"type": "plain_text", "text": "Add a task description"}, - }, - "label": {"type": "plain_text", "text": "Task description"}, - }, - ] - configure(blocks=blocks) - -ws = WorkflowStep( - callback_id="add_task", - edit=edit, - save=save, - execute=execute, -) -app.step(ws) -``` diff --git a/docs/_steps/creating_workflow_step.md b/docs/_steps/creating_workflow_step.md deleted file mode 100644 index 5d3db5000..000000000 --- a/docs/_steps/creating_workflow_step.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Creating workflow steps -lang: en -slug: creating-steps -order: 2 ---- - -
    - -To create a workflow step, Bolt provides the `WorkflowStep` class. - -When instantiating a new `WorkflowStep`, pass in the step's `callback_id` and a configuration object. - -The configuration object contains three keys: `edit`, `save`, and `execute`. Each of these keys must be a single callback or a list of callbacks. All callbacks have access to a `step` object that contains information about the workflow step event. - -After instantiating a `WorkflowStep`, you can pass it into `app.step()`. Behind the scenes, your app will listen and respond to the workflow stepโ€™s events using the callbacks provided in the configuration object. - -
    - -```python -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) -``` diff --git a/docs/_steps/executing_workflow_steps.md b/docs/_steps/executing_workflow_steps.md deleted file mode 100644 index b767112a0..000000000 --- a/docs/_steps/executing_workflow_steps.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: Executing workflow steps -lang: en -slug: executing-steps -order: 5 ---- - -
    - -When your workflow step is executed by an end user, your app will receive a [`workflow_step_execute` event](https://api.slack.com/events/workflow_step_execute). The `execute` callback in your `WorkflowStep` configuration will be run when this event is received. - -Using the `inputs` from the `save` callback, this is where you can make third-party API calls, save information to a database, update the user's Home tab, or decide the outputs that will be available to subsequent workflow steps by mapping values to the `outputs` object. - -Within the `execute` callback, your app must either call `complete()` to indicate that the step's execution was successful, or `fail()` to indicate that the step's execution failed. - -
    - -```python -def execute(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) -``` \ No newline at end of file diff --git a/docs/_steps/saving_workflow_step.md b/docs/_steps/saving_workflow_step.md deleted file mode 100644 index 34f02f6a4..000000000 --- a/docs/_steps/saving_workflow_step.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: Saving step configurations -lang: en -slug: saving-steps -order: 4 ---- - -
    - -After the configuration modal is opened, your app will listen for the `view_submission` event. The `save` callback in your `WorkflowStep` configuration will be run when this event is received. - -Within the `save` callback, the `update()` method can be used to save the builder's step configuration by passing in the following arguments: - -- `inputs` is an dictionary representing the data your app expects to receive from the user upon workflow step execution. -- `outputs` is a list of objects containing data that your app will provide upon the workflow step's completion. Outputs can then be used in subsequent steps of the workflow. -- `step_name` overrides the default Step name -- `step_image_url` overrides the default Step image - -To learn more about how to structure these parameters, [read the documentation](https://api.slack.com/reference/workflows/workflow_step). - -
    - -```python -def save(ack, view, update): - ack() - - values = view["state"]["values"] - task_name = values["task_name_input"]["name"] - task_description = values["task_description_input"]["description"] - - inputs = { - "task_name": {"value": task_name["value"]}, - "task_description": {"value": task_description["value"]} - } - outputs = [ - { - "type": "text", - "name": "task_name", - "label": "Task name", - }, - { - "type": "text", - "name": "task_description", - "label": "Task description", - } - ] - update(inputs=inputs, outputs=outputs) - -ws = WorkflowStep( - callback_id="add_task", - edit=edit, - save=save, - execute=execute, -) -app.step(ws) -``` \ No newline at end of file diff --git a/docs/_steps/workflow_steps_overview.md b/docs/_steps/workflow_steps_overview.md deleted file mode 100644 index eff6d4c5e..000000000 --- a/docs/_steps/workflow_steps_overview.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: Overview of Workflow Steps for apps -lang: en -slug: steps-overview -order: 1 ---- - -
    -Workflow Steps from apps allow your app to create and process custom workflow steps that users can add using [Workflow Builder](https://api.slack.com/workflows). - -A workflow step is made up of three distinct user events: - -- Adding or editing the step in a Workflow -- Saving or updating the step's configuration -- The end user's execution of the step - -All three events must be handled for a workflow step to function. - -Read more about workflow steps from apps in the [API documentation](https://api.slack.com/workflows/steps). - -
    diff --git a/docs/_tutorials/getting_started.md b/docs/_tutorials/getting_started.md deleted file mode 100644 index c71ba9436..000000000 --- a/docs/_tutorials/getting_started.md +++ /dev/null @@ -1,320 +0,0 @@ ---- -title: Getting started -order: 0 -slug: getting-started -lang: en -layout: tutorial -permalink: /tutorial/getting-started -redirect_from: - - /getting-started ---- -# Getting started with Bolt for Python - -
    -This guide is meant to walk you through getting up and running with a Slack app using Bolt for Python. Along the way, weโ€™ll create a new Slack app, set up your local environment, and develop an app that listens and responds to messages from a Slack workspace. -
    - -When you're finished, you'll have this โšก๏ธ[Getting Started with Slack app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started) to run, modify, and make your own. - ---- - -### Create an app -First thing's first: before you start developing with Bolt, you'll want to [create a Slack app](https://api.slack.com/apps/new). - -> ๐Ÿ’ก We recommend using a workspace where you won't disrupt real work getting done โ€” [you can create a new one for free](https://slack.com/get-started#create). - -After you fill out an app name (_you can change it later_) and pick a workspace to install it to, hit the `Create App` button and you'll land on your app's **Basic Information** page. - -This page contains an overview of your app in addition to important credentials you'll need later, like the `Signing Secret` under the **App Credentials** header. - -![Basic Information page](../assets/basic-information-page.png "Basic Information page") - -Look around, add an app icon and description, and then let's start configuring your app ๐Ÿ”ฉ - ---- - -### Tokens and installing apps -Slack apps use [OAuth to manage access to Slack's APIs](https://api.slack.com/docs/oauth). When an app is installed, you'll receive a token that the app can use to call API methods. - -There are two token types available to a Slack app: user (`xoxp`) and bot (`xoxb`) tokens. User tokens allow you to call API methods on behalf of users after they install or authenticate the app. There may be several user tokens for a single workspace. Bot tokens are associated with bot users, and are only granted once in a workspace where someone installs the app. The bot token your app uses will be the same no matter which user performed the installation. Bot tokens are the token type that _most_ apps use. - -For brevity, we're going to use bot tokens for this guide. - -Navigate to the **OAuth & Permissions** on the left sidebar and scroll down to the **Bot Token Scopes** section. Click **Add an OAuth Scope**. - -For now, we'll just add one scope: [`chat:write`](https://api.slack.com/scopes/chat:write). This grants your app the permission to post messages in channels it's a member of. - -Scroll up to the top of the OAuth & Permissions page and click **Install App to Workspace**. You'll be led through Slack's OAuth UI, where you should allow your app to be installed to your development workspace. - -Once you authorize the installation, you'll land on the **OAuth & Permissions** page and see a **Bot User OAuth Access Token**. - -![OAuth Tokens](../assets/bot-token.png "Bot OAuth Token") - -> ๐Ÿ’ก Treat your token like a password and [keep it safe](https://api.slack.com/docs/oauth-safety). Your app uses it to post and retrieve information from Slack workspaces. - ---- - -### Setting up your local project -With the initial configuration handled, it's time to set up a new Bolt project. This is where you'll write the code that handles the logic for your app. - -If you donโ€™t already have a project, letโ€™s create a new one. Create an empty directory: - -```shell -mkdir first-bolt-app -cd first-bolt-app -``` - -Next, we recommend using a [Python virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to manage your project's dependencies. This is a great way to prevent conflicts with your system's Python packages. Let's create and activate a new virtual environment with [Python 3.6 or later](https://www.python.org/downloads/): - -```shell -python3 -m venv .venv -source .venv/bin/activate -``` - -We can confirm that the virtual environment is active by checking that the path to `python3` is _inside_ your project ([a similar command is available on Windows](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#activating-a-virtual-environment)): - -```shell -which python3 -# Output: /path/to/first-bolt-app/.venv/bin/python3 -``` - -Before we install the Bolt for Python package to your new project, let's save the bot token and signing secret that was generated when you configured your app. These should be stored as environment variables and should *not* be saved in version control. - -1. **Copy your Signing Secret from the Basic Information page** and then store it in a new environment variable. The following example works on Linux and macOS; but [similar commands are available on Windows](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line/212153#212153). -```shell -export SLACK_SIGNING_SECRET= -``` - -2. **Copy your bot (xoxb) token from the OAuth & Permissions page** and store it in another environment variable. -```shell -export SLACK_BOT_TOKEN=xoxb- -``` - -Now, lets create your app. Install the `slack_bolt` Python package to your virtual environment using the following command: - -```shell -pip install slack_bolt -``` - -Create a new file called `app.py` in this directory and add the following code: - -```python -import os -from slack_bolt import App - -# Initializes your app with your bot token and signing secret -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") -) - -# Start your app -if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) -``` - -Your token and signing secret are enough to create your first Bolt app. Save your `app.py` file then on the command line run the following: - -```script -python3 app.py -``` - -Your app should let you know that it's up and running. - ---- - -### Setting up events -Your app behaves similarly to people on your team โ€” it can post messages, add emoji reactions, and more. To listen for events happening in a Slack workspace (like when a message is posted or when a reaction is posted to a message) you'll use the [Events API to subscribe to event types](https://api.slack.com/events-api). - -To enable events for your app, start by going back to your app configuration page (click on the app [from your app management page](https://api.slack.com/apps)). Click **Event Subscriptions** on the left sidebar. Toggle the switch labeled **Enable Events**. - -You'll see a text input labeled **Request URL**. The Request URL is a public URL where Slack will send HTTP POST requests corresponding to events you specify. - -> โš™๏ธWe've collected some of the most common hosting providers Slack developers use to host their apps [on our API site](https://api.slack.com/docs/hosting) - -When an event occurs, Slack will send your app some information about the event, like the user that triggered it and the channel it occurred in. Your app will process the details and can respond accordingly. - -
    - -

    Using a local Request URL for development

    -
    - -If youโ€™re just getting started with your app's development, you probably donโ€™t have a publicly accessible URL yet. Eventually, youโ€™ll want to set one up, but for now a development proxy like [ngrok](https://ngrok.com/) will create a public URL and tunnel requests to your own development environment. We've written a separate tutorial about [using ngrok with Slack for local development](https://api.slack.com/tutorials/tunneling-with-ngrok) that should help you get everything set up. - -Once youโ€™ve installed a development proxy, run it to begin forwarding requests to a specific port (weโ€™re using port `3000` for this example, but if you customized the port used to initialize your app use that port instead): - -```shell -ngrok http 3000 -``` - -![Running ngrok](../assets/ngrok.gif "Running ngrok") - -The output should show a generated URL that you can use (we recommend the one that starts with `https://`). This URL will be the base of your request URL, in this case `https://8e8ec2d7.ngrok.io`. - ---- -
    - -Now you have a public-facing URL for your app that tunnels to your local machine. The Request URL that you use in your app configuration is composed of your public-facing URL combined with the URL your app is listening on. By default, Bolt apps listen at `/slack/events` so our full request URL would be `https://8e8ec2d7.ngrok.io/slack/events`. - -> โš™๏ธBolt uses the `/slack/events` endpoint to listen to all incoming requests (whether shortcuts, events, or interactivity payloads). When configuring endpoints within your app configuration, you'll append `/slack/events` to all request URLs. - -Under the **Enable Events** switch in the **Request URL** box, go ahead and paste in your URL. As long as your Bolt app is still running, your URL should become verified. - -After your request URL is verified, scroll down to **Subscribe to Bot Events**. There are four events related to messages: -- `message.channels` listens for messages in public channels that your app is added to -- `message.groups` listens for messages in private channels that your app is added to -- `message.im` listens for messages in your app's DMs with users -- `message.mpim` listens for messages in multi-person DMs that your app is added to - -If you want your bot to listen to messages from everywhere it is added to, choose all four message events. After youโ€™ve selected the events you want your bot to listen to, click the green **Save Changes** button. - ---- - -### Listening and responding to a message -Your app is now ready for some logic. Let's start by using the `message()` method to attach a listener for messages. - -The following example listens and responds to all messages in channels/DMs where your app has been added that contain the word "hello": - -```python -import os -from slack_bolt import App - -# Initializes your app with your bot token and signing secret -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") -) - -# Listens to incoming messages that contain "hello" -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say(f"Hey there <@{message['user']}>!") - -# Start your app -if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) -``` - -If you restart your app, so long as your bot user has been added to the channel/DM, when you send any message that contains "hello", it will respond. - -This is a basic example, but it gives you a place to start customizing your app based on your own goals. Let's try something a little more interactive by sending a button rather than plain text. - ---- - -### Sending and responding to actions - -To use features like buttons, select menus, datepickers, modals, and shortcuts, youโ€™ll need to enable interactivity. Similar to events, you'll need to specify a URL for Slack to send the action (such as *user clicked a button*). - -Back on your app configuration page, click on **Interactivity & Shortcuts** on the left side. You'll see that there's another **Request URL** box. - -By default, Bolt is configured to use the same endpoint for interactive components that it uses for events, so use the same request URL as above (in the example, it was `https://8e8ec2d7.ngrok.io/slack/events`). Press the **Save Changes** button in the lower right hand corner, and that's it. Your app is set up to handle interactivity! - -![Configuring a Request URL](../assets/request-url-config.png "Configuring a Request URL") - -Now, let's go back to your app's code and add interactivity. This will consist of two steps: -- First, your app will send a message that contains a button. -- Next, your app will listen to the action of a user clicking the button and respond - -Below, the code from the last section is modified to send a message containing a button rather than just a string: - -```python -import os -from slack_bolt import App - -# Initializes your app with your bot token and signing secret -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") -) - -# Listens to incoming messages that contain "hello" -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say( - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Click Me"}, - "action_id": "button_click" - } - } - ], - text=f"Hey there <@{message['user']}>!" - ) - -# Start your app -if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) -``` - -The value inside of `say()` is now an object that contains an array of `blocks`. Blocks are the building components of a Slack message and can range from text to images to datepickers. In this case, your app will respond with a section block that includes a button as an accessory. Since we're using `blocks`, the `text` is a fallback for notifications and accessibility. - -You'll notice in the button `accessory` object, there is an `action_id`. This will act as a unique identifier for the button so your app can specify what action it wants to respond to. - -> ๐Ÿ’ก The [Block Kit Builder](https://app.slack.com/block-kit-builder) is an simple way to prototype your interactive messages. The builder lets you (or anyone on your team) mockup messages and generates the corresponding JSON that you can paste directly in your app. - -Now, if you restart your app and say "hello" in a channel your app is in, you'll see a message with a button. But if you click the button, nothing happens (*yet!*). - -Let's add a handler to send a followup message when someone clicks the button: - -```python -import os -from slack_bolt import App - -# Initializes your app with your bot token and signing secret -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") -) - -# Listens to incoming messages that contain "hello" -@app.message("hello") -def message_hello(message, say): - # say() sends a message to the channel where the event was triggered - say( - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Click Me"}, - "action_id": "button_click" - } - } - ], - text=f"Hey there <@{message['user']}>!" - ) - -@app.action("button_click") -def action_button_click(body, ack, say): - # Acknowledge the action - ack() - say(f"<@{body['user']['id']}> clicked the button") - -# Start your app -if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) -``` - -You can see that we used `app.action()` to listen for the `action_id` that we named `button_click`. If you restart your app and click the button, you'll see a new message from your app that says you clicked the button. - ---- - -### Next steps -You just built your first [Bolt for Python app](https://github.com/slackapi/bolt-python/tree/main/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 [Basic concepts](/bolt-python/concepts#basic) to learn about the different methods and features your Bolt app has access to. - -* Explore the different events your bot can listen to with the [`events()` method](/bolt-python/concepts#event-listening). All of the events are listed [on the API site](https://api.slack.com/events). - -* Bolt allows you to [call Web API methods](/bolt-python/concepts#web-api) with the client attached to your app. There are [over 220 methods](https://api.slack.com/methods) on our API site. - -* Learn more about the different token types [on our API site](https://api.slack.com/docs/token-types). Your app may need different tokens depending on the actions you want it to perform. \ No newline at end of file diff --git a/docs/assets/bolt-favicon.png b/docs/assets/bolt-favicon.png deleted file mode 100644 index bfe5456c1..000000000 Binary files a/docs/assets/bolt-favicon.png and /dev/null differ diff --git a/docs/assets/bolt-logo.svg b/docs/assets/bolt-logo.svg deleted file mode 100644 index 5077600d5..000000000 --- a/docs/assets/bolt-logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/assets/bolt-py-logo.svg b/docs/assets/bolt-py-logo.svg deleted file mode 100644 index 1dcab5261..000000000 --- a/docs/assets/bolt-py-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/assets/ngrok.gif b/docs/assets/ngrok.gif deleted file mode 100644 index c7c94d51a..000000000 Binary files a/docs/assets/ngrok.gif and /dev/null differ diff --git a/docs/assets/request-url-config.png b/docs/assets/request-url-config.png deleted file mode 100644 index 5315298a8..000000000 Binary files a/docs/assets/request-url-config.png and /dev/null differ diff --git a/docs/assets/signing-secret.png b/docs/assets/signing-secret.png deleted file mode 100644 index d32afa03e..000000000 Binary files a/docs/assets/signing-secret.png and /dev/null differ diff --git a/docs/assets/style.css b/docs/assets/style.css deleted file mode 100644 index 3af53a8c6..000000000 --- a/docs/assets/style.css +++ /dev/null @@ -1,451 +0,0 @@ -/* Color variables */ -:root { - --light-grey: #F8F8F8; - --grey: #868686; - --dark-grey: #616061; - --soft-grey: #DDDDDD; - --blue: #1264A3; - --green: #00B073; - --light-blue: #B8D1E3; - --white: #FFFFFF; - --black: #1D1C1D; -} - -html, body { - background-color: var(--white); - font-family: 'Noto Sans JP', 'Slack-Lato', sans-serif; - font-size: 100%; -} - -.content { - grid-area: content; -} - -span.beta { - background-color: #E8F5FA; - color: #1264A3; - padding: 4px 9px; - margin-right: 2px; - border-radius: 16px; - border: 1px solid #D4ECF6; - text-transform: uppercase; - font-weight: 600; - font-size: 0.7em; -} - -/* Sidebar */ -.panel { - position: fixed; - width: 20%; - height: 100%; - overflow: auto; - top: 0; - left: 0; - background-color: var(--light-grey); -} - -.panel .sidebar-content { - width: 75%; - margin: 30px auto 20px auto; -} - -.panel .sidebar-content .logo { - padding-top: 1.15em; - position: relative; -} - -.panel .sidebar-content .logo .icon img { - width: 2.15em; - margin-right: 6px; -} - -.panel .sidebar-content .logo .name { - font-weight: 800; - font-size: 2em; - vertical-align: bottom; -} - -.panel .sidebar-content .logo .version { - line-height: 1em; - vertical-align: bottom; -} - -.panel .sidebar-content .logo .version a { - color: var(--dark-grey); - background-color: var(--soft-grey); - font-size: 0.5em; - font-weight: 800; - padding: 4px 10px; - border-radius: 12px; - margin-left: 10px; - -} - -.panel .sidebar-content ul.sidebar-section { - list-style: none; - list-style-position: inside; - padding-top: 0.9em; - margin: 0 0 0 -0.5em; - font-size: 0.95em; -} - -.panel .sidebar-content ul.sidebar-section li { - border-radius: 8px; - padding: 2px 0 2px 8px; - margin: 0.1em 0; - color: var(--black); -} - -.panel .sidebar-content ul.sidebar-section li:hover { - background-color: #D7D7D7; -} - -.panel .sidebar-content ul.sidebar-section li.madeby:hover { - background-color: transparent; -} - -.panel .sidebar-content a:hover { - text-decoration: none; -} - -.panel .sidebar-content ul.sidebar-section li.active { - background-color: var(--blue); - color: var(--white); -} - -.panel .sidebar-content ul.sidebar-section li.title { - font-weight: 600; -} - -/* Main page */ -.header { - width: 95%; - margin: 0 auto 0.8em auto; - height: 5em; - padding-top: 1.5em; -} - -.header a:hover { - text-decoration: none; -} - -.header a.language-switcher { - color: var(--grey); - font-weight: 700; - padding: 6px 14px 9px; - font-size 0.9em; -} - -.header a.language-switcher:hover { - color: var(--black); -} - -.wrapper { - width: 100%; - margin: 0 auto; -} - -/* Main page content */ -.section-wrapper { - width: 90%; - margin: 0 auto 30px auto; - display: grid; - grid-gap: 20px; - grid-template-areas: - "head" - "body" - "code" - "secondary" - "divider" -} - -.tutorial-nav { - width: 20%; - position: fixed; -} - -.tutorial-nav ul { - margin-left: 3em; - padding-left: 1em; - border-width: 4px; - border-left-style: solid; - border-color: #F2F2F2; - border-image: linear-gradient( - to bottom, - #FFFFFF 0%, - #F2F2F2 6% 92%, - #FFFFFF 100% - ) 1 100%; - list-style: none; - padding-top: 1.5em; -} - -.circle { - background: #ddd; - border-radius: 50%; - height: 1em; - width: 1em; - float: left; - margin: 5px 0 0 -1.6em; -} - -.completed { - background: #53b3e1; -} - -.tutorial-nav ul li { - padding-bottom: 2.5em; -} - -.tutorial-nav a { - font-weight: 700; - font-size: 0.9em; - color: #757575; -} - -.tutorial-nav a:hover { - color: #000; - text-decoration: none; -} - -.tutorial { - width: 55%; - margin: 1em 0 0 33%; - padding-bottom: 2em; -} - -.tutorial img { - width: 85%; - margin: 0.2em auto; - display: block; - box-shadow: 0 0 15px #DDDDDD; -} - -.tutorial blockquote { - margin: 0 0 0 1em; - padding: 0 6em 0 2em; - border-radius: 6px; - border-left: 6px solid #DDD; - font-size: 1em; -} - -.tutorial h3 { - padding-bottom: 1em; -} - -.content .section-wrapper .highlighter-rouge { - grid-area: code; -} - -pre { - background-color: var(--light-grey) !important; - background-image: none; - padding: 1.2em; - border: 1px solid #DDDDDD; -} - -pre code pre { - padding: 0; - font-size: 0.9em; - line-height: 2.2em; -} - -pre code span { - padding: 0; - margin: 0; - height: 0; -} - -table, pre tbody, pre tbody td, pre tbody td pre { - padding: 0; - border: 0; - margin: 0; - text-align: left; -} - -pre tbody td.gl pre { - color: #999988; - padding-right: 1.6em; - user-select: none; -} - -.content .section-wrapper .section-content { - grid-area: body; -} - -.content .section-wrapper, .tutorial { - font-size: 1.15em; - line-height: 1.9em; -} - -.content .section-wrapper h3 { - grid-area: head; - font-size: 1.45em; - font-weight: 600; -} - -.content .section-wrapper hr { - grid-area: divider; - height: 1px; - border-top: 1px solid #DDD; - width: 100%; -} - -a:hover { - text-decoration: underline; -} - -/* Secondary content */ -.secondary-wrapper { - width: 100%; - grid-area: secondary; - margin: 0.6em auto 0 auto; -} - -.secondary-wrapper div.highlighter-rouge { - width: 50%; - float: left; - margin-top: 1em; -} - -.content .section-wrapper .secondary-content { - width: 45%; - float: left; - margin-right: 5%; - margin-top: 1em; -} - -.content .section-wrapper .secondary-content div.highlighter-rouge { - width: 100%; -} - -summary h4 { - display: inline; -} - -/* Responsive */ -@media (min-width: 1024px) { - .tutorial-nav ul { - margin-left: 5em; - } -} - -@media (min-width: 768px) { - .wrapper { - display: grid; - grid-template-columns: 20% 75%; - grid-template-areas: - "sidebar content" - } - - .section-wrapper { - grid-template-columns: 50% 50%; - grid-template-areas: - "head head" - "body code" - "secondary secondary" - "divider divider" - } -} - -@media (max-width: 768px) { - .panel { - display: none; - } - - .language-switcher { - display: none; - } - - .tutorial-nav { - display: none; - } - - .tutorial { - width: 85%; - margin: 1em auto; - } - - .wrapper { - display: grid; - grid-template-columns: 100%; - grid-template-areas: - "content" - } - - .section-wrapper { - grid-template-columns: 100%; - grid-template-areas: - "head" - "body" - "code" - "secondary" - "divider" - } -} - - -/* - * Github theme stylesheet from: http://jwarby.github.io/jekyll-pygments-themes/languages/javascript.html - */ - .highlight .hll { background-color: #ffffcc } - .highlight .c { color: #999988; } /* Comment */ - .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ - .highlight .k { color: #000000; font-weight: bold } /* Keyword */ - .highlight .o { color: #000000; font-weight: bold } /* Operator */ - .highlight .cm { color: #999988; } /* Comment.Multiline */ - .highlight .cp { color: #999999; font-weight: bold; } /* Comment.Preproc */ - .highlight .c1 { color: #999988; } /* Comment.Single */ - .highlight .cs { color: #999999; font-weight: bold; } /* Comment.Special */ - .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ - .highlight .ge { color: #000000; font-style: italic } /* Generic.Emph */ - .highlight .gr { color: #aa0000 } /* Generic.Error */ - .highlight .gh { color: #999999 } /* Generic.Heading */ - .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ - .highlight .go { color: #888888 } /* Generic.Output */ - .highlight .gp { color: #555555 } /* Generic.Prompt */ - .highlight .gs { font-weight: bold } /* Generic.Strong */ - .highlight .gu { color: #aaaaaa } /* Generic.Subheading */ - .highlight .gt { color: #aa0000 } /* Generic.Traceback */ - .highlight .kc { color: #000000; font-weight: bold } /* Keyword.Constant */ - .highlight .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */ - .highlight .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */ - .highlight .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */ - .highlight .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */ - .highlight .kt { color: #445588; font-weight: bold } /* Keyword.Type */ - .highlight .m { color: #009999 } /* Literal.Number */ - .highlight .s { color: #d01040 } /* Literal.String */ - .highlight .na { color: #008080 } /* Name.Attribute */ - .highlight .nb { color: #0086B3 } /* Name.Builtin */ - .highlight .nc { color: #445588; font-weight: bold } /* Name.Class */ - .highlight .no { color: #008080 } /* Name.Constant */ - .highlight .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */ - .highlight .ni { color: #800080 } /* Name.Entity */ - .highlight .ne { color: #990000; font-weight: bold } /* Name.Exception */ - .highlight .nf { color: #990000; font-weight: bold } /* Name.Function */ - .highlight .nl { color: #990000; font-weight: bold } /* Name.Label */ - .highlight .nn { color: #555555 } /* Name.Namespace */ - .highlight .nt { color: #000080 } /* Name.Tag */ - .highlight .nv { color: #008080 } /* Name.Variable */ - .highlight .ow { color: #000000; font-weight: bold } /* Operator.Word */ - .highlight .w { color: #bbbbbb } /* Text.Whitespace */ - .highlight .mf { color: #009999 } /* Literal.Number.Float */ - .highlight .mh { color: #009999 } /* Literal.Number.Hex */ - .highlight .mi { color: #009999 } /* Literal.Number.Integer */ - .highlight .mo { color: #009999 } /* Literal.Number.Oct */ - .highlight .sb { color: #d01040 } /* Literal.String.Backtick */ - .highlight .sc { color: #d01040 } /* Literal.String.Char */ - .highlight .sd { color: #d01040 } /* Literal.String.Doc */ - .highlight .s2 { color: #d01040 } /* Literal.String.Double */ - .highlight .se { color: #d01040 } /* Literal.String.Escape */ - .highlight .sh { color: #d01040 } /* Literal.String.Heredoc */ - .highlight .si { color: #d01040 } /* Literal.String.Interpol */ - .highlight .sx { color: #d01040 } /* Literal.String.Other */ - .highlight .sr { color: #009926 } /* Literal.String.Regex */ - .highlight .s1 { color: #d01040 } /* Literal.String.Single */ - .highlight .ss { color: #990073 } /* Literal.String.Symbol */ - .highlight .bp { color: #999999 } /* Name.Builtin.Pseudo */ - .highlight .vc { color: #008080 } /* Name.Variable.Class */ - .highlight .vg { color: #008080 } /* Name.Variable.Global */ - .highlight .vi { color: #008080 } /* Name.Variable.Instance */ - .highlight .il { color: #009999 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/english/_sidebar.json b/docs/english/_sidebar.json new file mode 100644 index 000000000..eab9d94f8 --- /dev/null +++ b/docs/english/_sidebar.json @@ -0,0 +1,215 @@ +[ + { + "type": "doc", + "id": "tools/bolt-python/index", + "label": "Bolt for Python", + "className": "sidebar-title" + }, + "tools/bolt-python/getting-started", + { "type": "html", "value": "
    " }, + "tools/bolt-python/building-an-app", + { + "type": "category", + "label": "Slack API calls", + "items": [ + "tools/bolt-python/concepts/message-sending", + "tools/bolt-python/concepts/web-api" + ] + }, + { + "type": "category", + "label": "Events", + "items": [ + "tools/bolt-python/concepts/message-listening", + "tools/bolt-python/concepts/event-listening" + ] + }, + { + "type": "category", + "label": "App UI & Interactivity", + "items": [ + "tools/bolt-python/concepts/acknowledge", + "tools/bolt-python/concepts/shortcuts", + "tools/bolt-python/concepts/commands", + "tools/bolt-python/concepts/actions", + "tools/bolt-python/concepts/opening-modals", + "tools/bolt-python/concepts/updating-pushing-views", + "tools/bolt-python/concepts/view-submissions", + "tools/bolt-python/concepts/select-menu-options", + "tools/bolt-python/concepts/app-home" + ] + }, + "tools/bolt-python/concepts/ai-apps", + { + "type": "category", + "label": "Custom Steps", + "items": [ + "tools/bolt-python/concepts/custom-steps", + "tools/bolt-python/concepts/custom-steps-dynamic-options" + ] + }, + { + "type": "category", + "label": "App Configuration", + "items": [ + "tools/bolt-python/concepts/socket-mode", + "tools/bolt-python/concepts/errors", + "tools/bolt-python/concepts/logging", + "tools/bolt-python/concepts/async" + ] + }, + { + "type": "category", + "label": "Middleware & Context", + "items": [ + "tools/bolt-python/concepts/global-middleware", + "tools/bolt-python/concepts/listener-middleware", + "tools/bolt-python/concepts/context" + ] + }, + "tools/bolt-python/concepts/lazy-listeners", + { + "type": "category", + "label": "Adaptors", + "items": [ + "tools/bolt-python/concepts/adapters", + "tools/bolt-python/concepts/custom-adapters" + ] + }, + { + "type": "category", + "label": "Authorization & Security", + "items": [ + "tools/bolt-python/concepts/authenticating-oauth", + "tools/bolt-python/concepts/authorization", + "tools/bolt-python/concepts/token-rotation" + ] + }, + { + "type": "category", + "label": "Experiments", + "items": ["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"] + } + ] + }, + { "type": "html", "value": "
    " }, + { + "type": "link", + "label": "Release notes", + "href": "https://github.com/slackapi/bolt-python/releases" + }, + { + "type": "link", + "label": "Code on GitHub", + "href": "https://github.com/SlackAPI/bolt-python" + }, + { + "type": "link", + "label": "Contributors Guide", + "href": "https://github.com/SlackAPI/bolt-python/blob/main/.github/contributing.md" + } +] diff --git a/docs/english/building-an-app.md b/docs/english/building-an-app.md new file mode 100644 index 000000000..bde340961 --- /dev/null +++ b/docs/english/building-an-app.md @@ -0,0 +1,482 @@ +--- +sidebar_label: Building an App +--- + +# Building an App with Bolt for Python + +This guide is meant to walk you through getting up and running with a Slack app using Bolt for Python. Along the way, weโ€™ll create a new Slack app, set up your local environment, and develop an app that listens and responds to messages from a Slack workspace. + +When you're finished, you'll have created the [Getting Started app](https://github.com/slackapi/bolt-python/tree/main/examples/getting_started) to run, modify, and make your own. โšก๏ธ + +--- + +### Create an app {#create-an-app} +First thing's first: before you start developing with Bolt, you'll want to [create a Slack app](https://api.slack.com/apps/new). + +:::tip[A place to test and learn] + +We recommend using a workspace where you won't disrupt real work getting done โ€” [you can create a new one for free](https://slack.com/get-started#create). + +::: + +After you fill out an app name (_you can change it later_) and pick a workspace to install it to, hit the `Create App` button and you'll land on your app's **Basic Information** page. + +This page contains an overview of your app in addition to important credentials you'll need later. + +![Basic Information page](/img/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](/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](/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/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/ai-apps.md b/docs/english/concepts/ai-apps.md new file mode 100644 index 000000000..3b057bc7e --- /dev/null +++ b/docs/english/concepts/ai-apps.md @@ -0,0 +1,512 @@ + +# Using AI in Apps {#using-ai-in-apps} + +The Slack platform offers features tailored for AI agents and assistants. Your apps can [utilize the `Assistant` class](#assistant) for a side-panel view designed with AI in mind, or they can utilize features applicable to messages throughout Slack, like [chat streaming](#text-streaming) and [feedback buttons](#adding-and-handling-feedback). + +If you're unfamiliar with using these feature within Slack, you may want to read the [API documentation on the subject](/ai/). Then come back here to implement them with Bolt! + +## The `Assistant` class instance {#assistant} + +:::info[Some features within this guide require a paid plan] +If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. +::: + +The [`Assistant`](/tools/bolt-js/reference#the-assistantconfig-configuration-object) class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. + +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 `get` and `save` methods. + +:::tip[Refer to the [reference docs](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html) to learn the available listener arguments.] +::: + +### Configuring your app to support the `Assistant` class {#configuring-assistant-class} + +1. Within [App Settings](https://api.slack.com/apps), enable the **Agents & AI Apps** feature. + +2. Within the App Settings **OAuth & Permissions** page, add the following scopes: + * [`assistant:write`](/reference/scopes/assistant.write) + * [`chat:write`](/reference/scopes/chat.write) + * [`im:history`](/reference/scopes/im.history) + +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) +* [`setTitle`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetTitle) +* [`setStatus`](https://docs.slack.dev/tools/bolt-python/reference/#slack_bolt.SetStatus) + +Within the `setStatus` utility, you can cycle through strings passed into a `loading_messages` array. + +```python +# This listener is invoked when the human user sends a reply in the assistant thread +@assistant.user_message +def respond_in_assistant_thread( + 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 [_Adding and handling feedback_](#adding-and-handling-feedback) section for adding feedback buttons with Block Kit. + +## Text streaming in messages {#text-streaming} + +Three Web API methods work together to provide users a text streaming experience: + +* the [`chat.startStream`](/reference/methods/chat.startStream) method starts the text stream, +* the [`chat.appendStream`](/reference/methods/chat.appendStream) method appends text to the stream, and +* the [`chat.stopStream`](/reference/methods/chat.stopStream) method stops it. + +Since you're using Bolt for Python, built upon the Python Slack SDK, you can use the [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) utility to streamline all three aspects of streaming in your app's messages. + +The following example uses OpenAI's streaming API with the new `chat_stream()` functionality, but you can substitute it with the AI client of your choice. + + +```python +import os +from typing import List, Dict + +import openai +from openai import Stream +from openai.types.responses import ResponseStreamEvent + +DEFAULT_SYSTEM_CONTENT = """ +You're an assistant in a Slack workspace. +Users in the workspace will ask you to help them write something or to think better about a specific topic. +You'll respond to those questions in a professional way. +When you include markdown text, convert them to Slack compatible ones. +When a prompt has Slack's special syntax like <@USER_ID> or <#CHANNEL_ID>, you must keep them as-is in your response. +""" + +def call_llm( + messages_in_thread: List[Dict[str, str]], + system_content: str = DEFAULT_SYSTEM_CONTENT, +) -> Stream[ResponseStreamEvent]: + openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + messages = [{"role": "system", "content": system_content}] + messages.extend(messages_in_thread) + response = openai_client.responses.create(model="gpt-4o-mini", input=messages, stream=True) + return response + +@assistant.user_message +def respond_in_assistant_thread( + ... +): + try: + ... + replies = client.conversations_replies( + channel=context.channel_id, + ts=context.thread_ts, + oldest=context.thread_ts, + limit=10, + ) + messages_in_thread: List[Dict[str, str]] = [] + for message in replies["messages"]: + role = "user" if message.get("bot_id") is None else "assistant" + messages_in_thread.append({"role": role, "content": message["text"]}) + + returned_message = call_llm(messages_in_thread) + + streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, + ) + + # Loop over OpenAI response stream + # https://platform.openai.com/docs/api-reference/responses/create + for event in returned_message: + if event.type == "response.output_text.delta": + streamer.append(markdown_text=f"{event.delta}") + else: + continue + + streamer.stop() + + except Exception as e: + logger.exception(f"Failed to handle a user message event: {e}") + say(f":warning: Something went wrong! ({e})") +``` + +## Adding and handling feedback {#adding-and-handling-feedback} + +Use the [feedback buttons block element](/reference/block-kit/block-elements/feedback-buttons-element/) to allow users to immediately provide feedback regarding your app's responses. Here's a quick example: + +```py +from typing import List +from slack_sdk.models.blocks import Block, ContextActionsBlock, FeedbackButtonsElement, FeedbackButtonObject + + +def create_feedback_block() -> List[Block]: + """ + Create feedback block with thumbs up/down buttons + + Returns: + Block Kit context_actions block + """ + blocks: List[Block] = [ + ContextActionsBlock( + elements=[ + FeedbackButtonsElement( + action_id="feedback", + positive_button=FeedbackButtonObject( + text="Good Response", + accessibility_label="Submit positive feedback on this response", + value="good-feedback", + ), + negative_button=FeedbackButtonObject( + text="Bad Response", + accessibility_label="Submit negative feedback on this response", + value="bad-feedback", + ), + ) + ] + ) + ] + return blocks +``` + +Use the `chat_stream` utility to render the feedback block at the bottom of your app's message. + +```js +... + streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, + ) + + # Loop over OpenAI response stream + # https://platform.openai.com/docs/api-reference/responses/create + for event in returned_message: + if event.type == "response.output_text.delta": + streamer.append(markdown_text=f"{event.delta}") + else: + continue + + feedback_block = create_feedback_block() + streamer.stop(blocks=feedback_block) +... +``` + +Then add a response for when the user provides feedback. + +```python +# Handle feedback buttons (thumbs up/down) +def handle_feedback(ack, body, client, logger: logging.Logger): + try: + ack() + message_ts = body["message"]["ts"] + channel_id = body["channel"]["id"] + feedback_type = body["actions"][0]["value"] + is_positive = feedback_type == "good-feedback" + + if is_positive: + client.chat_postEphemeral( + channel=channel_id, + user=body["user"]["id"], + thread_ts=message_ts, + text="We're glad you found this useful.", + ) + else: + client.chat_postEphemeral( + channel=channel_id, + user=body["user"]["id"], + thread_ts=message_ts, + text="Sorry to hear that response wasn't up to par :slightly_frowning_face: Starting a new chat may help with AI mistakes and hallucinations.", + ) + + logger.debug(f"Handled feedback: type={feedback_type}, message_ts={message_ts}") + except Exception as error: + logger.error(f":warning: Something went wrong! {error}") +``` + +## Full example: App Agent Template {#app-agent-template} + +Want to see the functionality described throughout this guide in action? We've created a [App Agent Template](https://github.com/slack-samples/bolt-python-assistant-template) repo for you to build off of. diff --git a/docs/_basic/publishing_views.md b/docs/english/concepts/app-home.md similarity index 51% rename from docs/_basic/publishing_views.md rename to docs/english/concepts/app-home.md index 842ba74b9..f4f15337f 100644 --- a/docs/_basic/publishing_views.md +++ b/docs/english/concepts/app-home.md @@ -1,19 +1,16 @@ ---- -title: Publishing views to App Home -lang: en -slug: app-home -order: 13 ---- +# Publishing views to App Home -
    -Home tabs are customizable surfaces accessible via the sidebar and search that allow apps to display views on a per-user basis. After enabling App Home within your app configuration, home tabs can be published and updated by passing a `user_id` and view payload to the `views.publish` method. +[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` event to listen for when users open your App Home. -
    +You can subscribe to the [`app_home_opened`](/reference/events/app_home_opened) event to listen for when users open your App Home. + +Refer to [the module document](https://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 open_modal(client, event, logger): +def update_home_tab(client, event, logger): try: # Call views.publish with the built-in client client.views_publish( @@ -34,13 +31,12 @@ def open_modal(client, event, logger): "type": "section", "text": { "type": "mrkdwn", - "text": "Learn how home tabs can be more useful and interactive ." + "text": "Learn how home tabs can be more useful and interactive ." } } ] } ) - except Exception as e: - logger.error(f"Error opening modal: {e}") -``` + logger.error(f"Error publishing home tab: {e}") +``` \ No newline at end of file diff --git a/docs/_advanced/async.md b/docs/english/concepts/async.md similarity index 75% rename from docs/_advanced/async.md rename to docs/english/concepts/async.md index 02b60cc4e..e6ae28fc6 100644 --- a/docs/_advanced/async.md +++ b/docs/english/concepts/async.md @@ -1,15 +1,8 @@ ---- -title: Using async -lang: en -slug: async -order: 2 ---- +# 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 to make API requests, which means you'll need to install `aiohttp` (by adding to `requirements.txt` or running `pip install aiohttp`). +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` folder. -
    +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 @@ -29,12 +22,7 @@ if __name__ == "__main__": app.start(3000) ``` -
    - -

    Using other frameworks

    -
    - -
    +## 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. @@ -48,7 +36,6 @@ 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 @@ -77,5 +64,4 @@ async def endpoint(req: Request): 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/_basic/authenticating_oauth.md b/docs/english/concepts/authenticating-oauth.md similarity index 77% rename from docs/_basic/authenticating_oauth.md rename to docs/english/concepts/authenticating-oauth.md index 0de17ffe9..4ce9b205b 100644 --- a/docs/_basic/authenticating_oauth.md +++ b/docs/english/concepts/authenticating-oauth.md @@ -1,21 +1,14 @@ ---- -title: Authenticating with OAuth -lang: en -slug: authenticating-oauth -order: 15 ---- +# 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. -Slack apps installed on multiple workspaces will need to implement OAuth, then store installation information (like access tokens) securely. By providing `client_id`, `client_secret`, `scopes`, `installation_store`, and `state_store` when initializing App, Bolt for Python will handle the work of setting up OAuth routes and verifying state. If youโ€™re implementing a custom adapter, you can make use of our [OAuth library](https://slack.dev/python-slack-sdk/oauth/), which is what Bolt for Python uses under the hood. - -Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your appโ€™s installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. +Bolt for Python will create a **Redirect URL** `slack/oauth_redirect`, which Slack uses to redirect users after they complete your app's installation flow. You will need to add this **Redirect URL** in your app configuration settings under **OAuth and Permissions**. This path can be configured in the `OAuthSettings` argument described below. Bolt for Python will also create a `slack/install` route, where you can find an **Add to Slack** button for your app to perform direct installs of your app. If you need any additional authorizations (user tokens) from users inside a team when your app is already installed or a reason to dynamically generate an install URL, you can pass your own custom URL generator to `oauth_settings` as `authorize_url_generator`. -To learn more about the OAuth installation flow with Slack, [read the API documentation](https://api.slack.com/authentication/oauth-v2). +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 @@ -28,22 +21,18 @@ oauth_settings = OAuthSettings( client_id=os.environ["SLACK_CLIENT_ID"], client_secret=os.environ["SLACK_CLIENT_SECRET"], scopes=["channels:read", "groups:read", "chat:write"], - installation_store=FileInstallationStore(base_dir="./data"), - state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data") + installation_store=FileInstallationStore(base_dir="./data/installations"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states") ) app = App( - signing_secret=os.environ["SIGNING_SECRET"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], oauth_settings=oauth_settings ) ``` -
    - -

    Customizing OAuth defaults

    -
    +## 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 @@ -52,8 +41,6 @@ You can override the default OAuth using `oauth_settings`, which can be passed i - `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 @@ -83,7 +70,7 @@ from slack_sdk.oauth.state_store import FileOAuthStateStore app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), - installation_store=FileInstallationStore(base_dir="./data"), + installation_store=FileInstallationStore(base_dir="./data/installations"), oauth_settings=OAuthSettings( client_id=os.environ.get("SLACK_CLIENT_ID"), client_secret=os.environ.get("SLACK_CLIENT_SECRET"), @@ -92,10 +79,8 @@ app = App( redirect_uri=None, install_path="/slack/install", redirect_uri_path="/slack/oauth_redirect", - state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states"), callback_options=callback_options, ), ) -``` - -
    +``` \ No newline at end of file diff --git a/docs/_advanced/authorization.md b/docs/english/concepts/authorization.md similarity index 80% rename from docs/_advanced/authorization.md rename to docs/english/concepts/authorization.md index 0a15c8a35..f6a258491 100644 --- a/docs/_advanced/authorization.md +++ b/docs/english/concepts/authorization.md @@ -1,23 +1,18 @@ ---- -title: Authorization -lang: en -slug: authorization -order: 5 ---- +# Authorization -
    -Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack event. +Authorization is the process of determining which Slack credentials should be available while processing an incoming Slack request. -Apps installed on a single workspace can simply pass their bot token into the `App` constructor using the `token` parameter. However, if your app will be installed on multiple workspaces, you have two options. The easier option is to use the built-in OAuth support. This will handle setting up OAuth routes and verifying state. Read the section on [authenticating with OAuth](#authenticating-oauth) for details. +Apps installed on a single workspace can simply pass their bot token into the `App` constructor using the `token` parameter. However, if your app will be installed on multiple workspaces, you have two options. The easier option is to use the built-in OAuth support. This will handle setting up OAuth routes and verifying state. Read the section on [authenticating with OAuth](/tools/bolt-python/concepts/authenticating-oauth) for details. -For a more custom solution, you can set the `authorize` parameter to a function upon `App` instantiation. The `authorize` function should return [an instance of `AuthorizeResult`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py), which contains information about who and where the event is coming from. +For a more custom solution, you can set the `authorize` parameter to a function upon `App` instantiation. The `authorize` function should return [an instance of `AuthorizeResult`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/authorization/authorize_result.py), which contains information about who and where the request is coming from. `AuthorizeResult` should have a few specific properties, all of type `str`: - Either **`bot_token`** (xoxb) *or* **`user_token`** (xoxp) are **required**. Most apps will use `bot_token` by default. Passing a token allows built-in functions (like `say()`) to work. - **`bot_user_id`** and **`bot_id`**, if using a `bot_token`. -- **`enterprise_id`** and **`team_id`**, which can be found in events sent to your app. +- **`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 @@ -48,8 +43,8 @@ def authorize(enterprise_id, team_id, logger): # You can implement your own logic to fetch token here for team in installations: # enterprise_id doesn't exist for some teams - is_valid_enterprise = True if (("enterprise_id" not in team) or (enterprise_id == team["enterprise_id"])) else False - if ((is_valid_enterprise == True) and (team["team_id"] == team_id)): + 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( diff --git a/docs/_basic/listening_responding_commands.md b/docs/english/concepts/commands.md similarity index 59% rename from docs/_basic/listening_responding_commands.md rename to docs/english/concepts/commands.md index b6e1aee65..cd772c57b 100644 --- a/docs/_basic/listening_responding_commands.md +++ b/docs/english/concepts/commands.md @@ -1,27 +1,22 @@ ---- -title: Listening and responding to commands -lang: en -slug: commands -order: 9 ---- +# 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`. -Your app can use the `command()` method to listen to incoming slash command events. The method requires a `command_name` of type `str`. +Commands must be acknowledged with `ack()` to inform Slack your app has received the request. -Commands must be acknowledged with `ack()` to inform Slack your app has received the event. - -There are two ways to respond to slash commands. The first way is to use `say()`, which accepts a string or JSON payload. The second is `respond()` which is a utility for the `response_url`. These are explained in more depth in the [responding to actions](#action-respond) section. +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, say, command): +def repeat_text(ack, respond, command): # Acknowledge command request ack() - say(f"{command['text']}") + respond(f"{command['text']}") ``` diff --git a/docs/_advanced/context.md b/docs/english/concepts/context.md similarity index 85% rename from docs/_advanced/context.md rename to docs/english/concepts/context.md index cc29d239c..46684ea28 100644 --- a/docs/_advanced/context.md +++ b/docs/english/concepts/context.md @@ -1,18 +1,13 @@ ---- -title: Adding context -lang: en -slug: context -order: 7 ---- +# Adding 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`. +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 userId +# Listener middleware to fetch tasks from external system using user ID def fetch_tasks(context, event, next): user = event["user"] try: @@ -68,4 +63,4 @@ def show_tasks(event, client, context): "blocks": context["blocks"] } ) -``` \ No newline at end of file +``` diff --git a/docs/_advanced/custom_adapters.md b/docs/english/concepts/custom-adapters.md similarity index 86% rename from docs/_advanced/custom_adapters.md rename to docs/english/concepts/custom-adapters.md index f06af77c1..21f7f33e0 100644 --- a/docs/_advanced/custom_adapters.md +++ b/docs/english/concepts/custom-adapters.md @@ -1,12 +1,6 @@ ---- -title: Custom adapters -lang: en -slug: custom-adapters -order: 1 ---- +# Custom adapters -
    -[Adapters](#adapters) are flexible and can be adjusted based on the framework you prefer. There are two necessary components of adapters: +[Adapters](/tools/bolt-python/concepts/adapters) are flexible and can be adjusted based on the framework you prefer. There are two necessary components of adapters: - `__init__(app: App)`: Constructor that accepts and stores an instance of the Bolt `App`. - `handle(req: Request)`: Function (typically named `handle()`) that receives incoming Slack requests, parses them to conform to an instance of [`BoltRequest`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/request/request.py), then dispatches them to the stored Bolt app. @@ -20,10 +14,11 @@ order: 1 | `headers: Dict[str, Union[str, List[str]]]` | Request headers | No | | `context: BoltContext` | Any context for the request | No | -`BoltRequest` will return [an instance of `BoltResponse`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py) from the Bolt app. +Your adapter will return [an instance of `BoltResponse`](https://github.com/slackapi/bolt-python/blob/main/slack_bolt/response/response.py) from the Bolt app. For more in-depth examples of custom adapters, look at the implementations of the [built-in adapters](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter). -
    + +## Example ```python # Necessary imports for Flask 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/_advanced/errors.md b/docs/english/concepts/errors.md similarity index 62% rename from docs/_advanced/errors.md rename to docs/english/concepts/errors.md index 7899485ce..7b40adb7f 100644 --- a/docs/_advanced/errors.md +++ b/docs/english/concepts/errors.md @@ -1,15 +1,10 @@ ---- -title: Handling errors -lang: en -slug: errors -order: 3 ---- +# 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`. +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 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/_advanced/global_middleware.md b/docs/english/concepts/global-middleware.md similarity index 62% rename from docs/_advanced/global_middleware.md rename to docs/english/concepts/global-middleware.md index efebea050..7b7bdb059 100644 --- a/docs/_advanced/global_middleware.md +++ b/docs/english/concepts/global-middleware.md @@ -1,15 +1,12 @@ ---- -title: Global middleware -lang: en -slug: global-middleware -order: 6 ---- +# Global middleware -
    -Global middleware is run for all incoming events, before any listener middleware. You can add any number of global middleware to your app by passing middleware functions to `app.use()`. Middleware functions are called with the same arguments as listeners, with an additional `next()` function. +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 @@ -32,3 +29,4 @@ def auth_acme(client, context, logger, payload, next): # 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/_advanced/logging.md b/docs/english/concepts/logging.md similarity index 64% rename from docs/_advanced/logging.md rename to docs/english/concepts/logging.md index f36161153..599431550 100644 --- a/docs/_advanced/logging.md +++ b/docs/english/concepts/logging.md @@ -1,15 +1,10 @@ ---- -title: Logging -lang: en -slug: logging -order: 4 ---- +# 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`. +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 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..87c433129 --- /dev/null +++ b/docs/english/concepts/message-sending.md @@ -0,0 +1,100 @@ +# 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 AI chatbot behavior. This is done through three Web API methods: + +* [`chat_startStream`](/reference/methods/chat.startStream) +* [`chat_appendStream`](/reference/methods/chat.appendStream) +* [`chat_stopStream`](/reference/methods/chat.stopStream) + +The Python Slack SDK provides a [`chat_stream()`](https://docs.slack.dev/tools/python-slack-sdk/reference/web/client.html#slack_sdk.web.client.WebClient.chat_stream) helper utility to streamline calling these methods. Here's an excerpt from our [Assistant template app](https://github.com/slack-samples/bolt-python-assistant-template): + +```python +streamer = client.chat_stream( + channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, + thread_ts=thread_ts, +) + +# Loop over OpenAI response stream +# https://platform.openai.com/docs/api-reference/responses/create +for event in returned_message: + if event.type == "response.output_text.delta": + streamer.append(markdown_text=f"{event.delta}") + else: + continue + +feedback_block = create_feedback_block() +streamer.stop(blocks=feedback_block) +``` + +In that example, a [feedback buttons](/reference/block-kit/block-elements/feedback-buttons-element) block element is passed to `streamer.stop` to provide feedback buttons to the user at the bottom of the message. Interaction with these buttons will send a block action event to your app to receive the feedback. + +```python +def create_feedback_block() -> List[Block]: + blocks: List[Block] = [ + ContextActionsBlock( + elements=[ + FeedbackButtonsElement( + action_id="feedback", + positive_button=FeedbackButtonObject( + text="Good Response", + accessibility_label="Submit positive feedback on this response", + value="good-feedback", + ), + negative_button=FeedbackButtonObject( + text="Bad Response", + accessibility_label="Submit negative feedback on this response", + value="bad-feedback", + ), + ) + ] + ) + ] + return blocks +``` + +For information on calling the `chat_*Stream` API methods without the helper utility, see the [_Sending streaming messages_](/tools/python-slack-sdk/web#sending-streaming-messages) section of the Python Slack SDK docs. \ No newline at end of file diff --git a/docs/_basic/opening_modals.md b/docs/english/concepts/opening-modals.md similarity index 63% rename from docs/_basic/opening_modals.md rename to docs/english/concepts/opening-modals.md index 9c71763e7..01716f613 100644 --- a/docs/_basic/opening_modals.md +++ b/docs/english/concepts/opening-modals.md @@ -1,26 +1,21 @@ ---- -title: Opening modals -lang: en -slug: opening-modals -order: 10 ---- +# 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. -Modals are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid `trigger_id` and a view payload to the built-in client's `views.open` method. +Your app receives `trigger_id` parameters in payloads sent to your Request URL triggered user invocation like a slash command, button press, or interaction with a select menu. -Your app receives `trigger_id`s in payloads sent to your Request URL that are triggered by user invocations, like a shortcut, button press, or interaction with a select menu. +Read more about modal composition in the [API documentation](/surfaces/modals#composing_views). -Read more about modal composition in the API documentation. +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(); + ack() # Call views_open with the built-in client client.views_open( # Pass a valid trigger_id within 3 seconds of receiving it 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/_basic/listening_responding_shortcuts.md b/docs/english/concepts/shortcuts.md similarity index 60% rename from docs/_basic/listening_responding_shortcuts.md rename to docs/english/concepts/shortcuts.md index 0a73d6de7..b28f0b352 100644 --- a/docs/_basic/listening_responding_shortcuts.md +++ b/docs/english/concepts/shortcuts.md @@ -1,34 +1,25 @@ ---- -title: Listening and responding to shortcuts -lang: en -slug: shortcuts -order: 8 ---- +# Listening & responding to shortcuts -
    +The `shortcut()` method supports both [global shortcuts](/interactivity/implementing-shortcuts#global) and [message shortcuts](/interactivity/implementing-shortcuts#messages). -The `shortcut()` method supports both [global shortcuts](https://api.slack.com/interactivity/shortcuts/using#global_shortcuts) and [message shortcuts](https://api.slack.com/interactivity/shortcuts/using#message_shortcuts). +Shortcuts are invokable entry points to apps. Global shortcuts are available from within search 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 are invokable entry points to apps. Global shortcuts are available from within search in Slack. Message shortcuts are available in the context menus of messages. Your app can use the `shortcut()` method to listen to incoming shortcut events. The method requires a `callback_id` parameter of type `str` or `re.Pattern`. +Shortcuts must be acknowledged with `ack()` to inform Slack that your app has received the request. -Shortcuts must be acknowledged with `ack()` to inform Slack that your app has received the event. - -Shortcuts include a `trigger_id` which an app can use to [open a modal](#creating-modals) that confirms the action the user is taking. +Shortcuts include a `trigger_id` which an app can use to [open a modal](/tools/bolt-python/concepts/opening-modals) that confirms the action the user is taking. When setting up shortcuts within your app configuration, as with other URLs, you'll append `/slack/events` to your request URL. -โš ๏ธ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](https://api.slack.com/reference/block-kit/block-elements#conversation_select) element within a modal. Message shortcuts do include a channel ID. - -
    +โš ๏ธ Note that global shortcuts do **not** include a channel ID. If your app needs access to a channel ID, you may use a [`conversations_select`](/reference/block-kit/block-elements/multi-select-menu-element#conversation_multi_select) element within a modal. Message shortcuts do include a channel ID. +Refer to [the module document](https://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 one of the built-in WebClients + # Call the views_open method using the built-in WebClient client.views_open( trigger_id=shortcut["trigger_id"], # A simple view payload for a modal @@ -41,7 +32,7 @@ def open_modal(ack, shortcut, client): "type": "section", "text": { "type": "mrkdwn", - "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." } }, { @@ -58,18 +49,12 @@ def open_modal(ack, shortcut, client): ) ``` -
    - -

    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`. -
    +## 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 middleware will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' +# Your listener will only be called when the callback_id matches 'open_modal' AND the type matches 'message_action' @app.shortcut({"callback_id": "open_modal", "type": "message_action"}) def open_modal(ack, shortcut, client): # Acknowledge the shortcut request @@ -86,7 +71,7 @@ def open_modal(ack, shortcut, client): "type": "section", "text": { "type": "mrkdwn", - "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + "text": "About the simplest modal you could conceive of :smile:\n\nMaybe or ." } }, { @@ -101,6 +86,4 @@ def open_modal(ack, shortcut, client): ] } ) -``` - -
    +``` \ 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/_basic/updating_pushing_modals.md b/docs/english/concepts/updating-pushing-views.md similarity index 59% rename from docs/_basic/updating_pushing_modals.md rename to docs/english/concepts/updating-pushing-views.md index d017be210..8c05e79c8 100644 --- a/docs/_basic/updating_pushing_modals.md +++ b/docs/english/concepts/updating-pushing-views.md @@ -1,24 +1,18 @@ ---- -title: Updating and pushing views -lang: en -slug: updating-pushing-views -order: 11 ---- +# Updating & pushing views -
    +Modals contain a stack of views. When you call [`views_open`](https://api./reference/methods/views.open/slack.com/methods/views.open), you add the root view to the modal. After the initial call, you can dynamically update a view by calling [`views_update`](/reference/methods/views.update/), or stack a new view on top of the root view by calling [`views_push`](/reference/methods/views.push/) -Modals contain a stack of views. When you call `views_open`, you add the root view to the modal. After the initial call, you can dynamically update a view by calling `views_update`, or stack a new view on top of the root view by calling `views_push`. +## The `views_update` method -**`views_update`**
    To update a view, you can use the built-in client to call `views_update` with the `view_id` that was generated when you opened the view, and a new `view` including the updated `blocks` list. If you're updating the view when a user interacts with an element inside of an existing view, the `view_id` will be available in the `body` of the request. -**`views_push`**
    -To push a new view onto the view stack, you can use the built-in client to call `views_push` with a valid `trigger_id` a new view payload. The arguments for `views_push` is the same as opening modals. After you open a modal, you may only push two additional views onto the view stack. +## The `views_push` method -Learn more about updating and pushing views in our API documentation. +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") @@ -50,4 +44,4 @@ def update_modal(ack, body, client): ] } ) -``` +``` \ 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..4ff4c2da7 --- /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 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/experiments.md b/docs/english/experiments.md new file mode 100644 index 000000000..681c8cbc6 --- /dev/null +++ b/docs/english/experiments.md @@ -0,0 +1,34 @@ +# Experiments + +Bolt for Python includes experimental features still under active development. These features may be fleeting, may not be perfectly polished, and should be thought of as available for use "at your own risk." + +Experimental features are categorized as `semver:patch` until the experimental status is removed. + +We love feedback from our community, so we encourage you to explore and interact with the [GitHub repo](https://github.com/slackapi/bolt-python). Contributions, bug reports, and any feedback are all helpful; let us nurture the Slack CLI together to help make building Slack apps more pleasant for everyone. + +## Available experiments +* [Agent listener argument](#agent) + +## Agent listener argument {#agent} + +The `agent: BoltAgent` listener argument provides access to AI agent-related features. + +The `BoltAgent` and `AsyncBoltAgent` classes offer a `chat_stream()` method that comes pre-configured with event context defaults: `channel_id`, `thread_ts`, `team_id`, and `user_id` fields. + +The listener argument is wired into the Bolt `kwargs` injection system, so listeners can declare it as a parameter or access it via the `context.agent` property. + +### Example + +```python +from slack_bolt import BoltAgent + +@app.event("app_mention") +def handle_mention(agent: BoltAgent): + stream = agent.chat_stream() + stream.append(markdown_text="Hello!") + stream.stop() +``` + +### Limitations + +The `chat_stream()` method currently only works when the `thread_ts` field is available in the event context (DMs and threaded replies). Top-level channel messages do not have a `thread_ts` field, and the `ts` field is not yet provided to `BoltAgent`. \ No newline at end of file diff --git a/docs/english/getting-started.md b/docs/english/getting-started.md new file mode 100644 index 000000000..934dd3bae --- /dev/null +++ b/docs/english/getting-started.md @@ -0,0 +1,333 @@ +--- +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/building-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](/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! + +## Adding AI features {#ai-features} + +Now that you're familiar with a basic app setup, try it out again, this time using the AI agent template! + + + + +Get started with the agent template: + +```sh +$ slack create ai-app --template slack-samples/bolt-python-assistant-template +$ cd ai-app +``` + + + + +Get started with the agent template: + +```sh +$ git clone https://github.com/slack-samples/bolt-python-assistant-template ai-app +$ cd ai-app +``` + +Using this method, be sure to set the app and bot tokens as we did in the [Running the app](#running-the-app) section above. + + + + +Once the project is created, update the `.env.sample` file by setting the `OPENAI_API_KEY` with the value of your key and removing the `.sample` from the file name. + +In the `ai` folder of this app, you'll find default instructions for the LLM and an OpenAI client setup. + +The `listeners` include utilities intended for messaging with an LLM. Those are outlined in detail in the guide to [Using AI in apps](/tools/bolt-python/concepts/ai-apps) and [Sending messages](/tools/bolt-python/concepts/message-sending). + +## Next steps {#next-steps} + +Congrats once more on getting up and running with this quick start. + +:::info[Dive deeper] + +Follow along with the steps that went into making this app on the [building an app](/tools/bolt-python/building-an-app) guide for an educational overview. + +::: + +You can now continue customizing your app with various features to make it right for whatever job's at hand. Here are some ideas about what to explore next: + +- Explore the different events your bot can listen to with the [`app.event()`](/tools/bolt-python/concepts/event-listening) method. See the full events reference [here](/reference/events). +- Bolt allows you to call [Web API](/tools/bolt-python/concepts/web-api) methods with the client attached to your app. There are [over 200 methods](/reference/methods) available. +- Learn more about the different [token types](/authentication/tokens) and [authentication setups](/tools/bolt-python/concepts/authenticating-oauth). Your app might need different tokens depending on the actions you want to perform or for installations to multiple workspaces. +- Receive events using HTTP for various deployment methods, such as deploying to Heroku or AWS Lambda. +- Read on [app design](/surfaces/app-design) and compose fancy messages with blocks using [Block Kit Builder](https://app.slack.com/block-kit-builder) to prototype messages. diff --git a/docs/english/index.md b/docs/english/index.md new file mode 100644 index 000000000..212bd9690 --- /dev/null +++ b/docs/english/index.md @@ -0,0 +1,21 @@ +# 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`. + +## 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..72005f817 --- /dev/null +++ b/docs/english/tutorial/ai-chatbot/ai-chatbot.md @@ -0,0 +1,239 @@ +# AI Chatbot + +In this tutorial, you'll learn how to bring the power of AI into your Slack workspace using a chatbot called Bolty that uses Anthropic or OpenAI. Here's what we'll do with this sample app: + +1. Create your app from an app manifest and clone a starter template +2. Set up and run your local project +3. Create a workflow using Workflow Builder to summarize messages in conversations +4. Select your preferred API and model to customize Bolty's responses +5. Interact with Bolty via direct message, the `/ask-bolty` slash command, or by mentioning the app in conversations + +## 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. +- an Anthropic or OpenAI account with sufficient credits, and in which you have generated a secret key. + +**Skip to the code** +If you'd rather skip the tutorial and just head straight to the code, you can use our [Bolt for Python AI Chatbot sample](https://github.com/slack-samples/bolt-python-ai-chatbot) as a template. + +## Creating your app {#create-app} + +1. Navigate to the [app creation page](https://api.slack.com/apps/new) and select **From a manifest**. +2. Select the workspace you want to install the application in. +3. Copy the contents of the [`manifest.json`](https://github.com/slack-samples/bolt-python-ai-chatbot/blob/main/manifest.json) file into the text box that says **Paste your manifest code here** (within the **JSON** tab) and click **Next**. +4. Review the configuration and click **Create**. +5. You're now in your app configuration's **Basic Information** page. Navigate to the **Install App** link in the left nav and click **Install to Workspace**, then **Allow** on the screen that follows. + +### Obtaining and storing your environment variables {#environment-variables} + +Before you'll be able to successfully run the app, you'll need to first obtain and set some environment variables. + +#### Slack tokens {#slack-tokens} + +From your app's page on [app settings](https://api.slack.com/apps) collect an app and bot token: + +1. On the **Install App** page, copy your **Bot User OAuth Token**. You will store this in your environment as `SLACK_BOT_TOKEN` (we'll get to that next). +2. Navigate to **Basic Information** and in the **App-Level Tokens** section , click **Generate Token and Scopes**. Add the [`connections:write`](/reference/scopes/connections.write) scope, name the token, and click **Generate**. (For more details, refer to [understanding OAuth scopes for bots](/authentication/tokens#bot)). Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. + +To store your tokens and environment variables, run the following commands in the terminal. Replace the placeholder values with your bot and app tokens collected above: + +**For macOS** + +```bash +export SLACK_BOT_TOKEN= +export SLACK_APP_TOKEN= +``` + +**For Windows** + +```pwsh +set SLACK_BOT_TOKEN= +set SLACK_APP_TOKEN= +``` + +#### Provider tokens {#provider-tokens} + +Models from different AI providers are available if the corresponding environment variable is added as shown in the sections below. + +##### Anthropic {#anthropic} + +To interact with Anthropic models, navigate to your Anthropic account dashboard to [create an API key](https://console.anthropic.com/settings/keys), then export the key as follows: + +```bash +export ANTHROPIC_API_KEY= +``` + +##### Google Cloud Vertex AI {#google-cloud-vertex-ai} + +To use Google Cloud Vertex AI, [follow this quick start](https://cloud.google.com/vertex-ai/generative-ai/docs/start/quickstarts/quickstart-multimodal#expandable-1) to create a project for sending requests to the Gemini API, then gather [Application Default Credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc) with the strategy to match your development environment. + +Once your project and credentials are configured, export environment variables to select from Gemini models: + +```bash +export VERTEX_AI_PROJECT_ID= +export VERTEX_AI_LOCATION= +``` + +The project location can be located under the **Region** on the [Vertex AI](https://console.cloud.google.com/vertex-ai) dashboard, as well as more details about available Gemini models. + +##### OpenAI {#openai} + +Unlock the OpenAI models from your OpenAI account dashboard by clicking [create a new secret key](https://platform.openai.com/api-keys), then export the key like so: + +```bash +export OPENAI_API_KEY= +``` + +## Setting up and running your local project {#configure-project} + +Clone the starter template onto your machine by running the following command: + +```bash +git clone https://github.com/slack-samples/bolt-python-ai-chatbot.git +``` + +Change into the new project directory: + +```bash +cd bolt-python-ai-chatbot +``` + +Start your Python virtual environment: + +**For macOS** + +```bash +python3 -m venv .venv +source .venv/bin/activate +``` + +**For Windows** + +```bash +py -m venv .venv +.venv\Scripts\activate +``` + +Install the required dependencies: + +```bash +pip install -r requirements.txt +``` + +Start your local server: + +```bash +python app.py +``` + +If your app is 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. +- To use the Bolt for Python SDK to develop on the automations platform, refer to the [Create a workflow step for Workflow Builder: Bolt for Python](/tools/bolt-python/tutorial/custom-steps-workflow-builder-new) tutorial. diff --git a/docs/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..c3c5e2af7 --- /dev/null +++ b/docs/english/tutorial/custom-steps-workflow-builder-existing/custom-steps-workflow-builder-existing.md @@ -0,0 +1,281 @@ +# 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 `package.json` file to reflect version 1.20.0 of Bolt, then run the following command 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'], + message=body['message'], + 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..1dceed45a --- /dev/null +++ b/docs/english/tutorial/custom-steps-workflow-builder-new/custom-steps-workflow-builder-new.md @@ -0,0 +1,356 @@ +# 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 +npm install +``` + +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 `npm start`. Soon we'll start our local development server and see what our sample code is all about! But first, we need to store those tokens we gathered as environment variables. + +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 +npm start +``` + +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..66dc16198 --- /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(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` requires you include an `outputs` object that matches your step definition in [`output_parameters`](#inputs-outputs). +`fail` | A utility method that invokes `functions.completeError`. True to its name, this method signals to Slack that a step has failed to complete. The `fail` method requires an argument of `error` to be sent along with it, which is used to help users understand what went wrong. +`inputs` | An alias for the `input_parameters` that were provided to the step upon execution. + +## 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/assets/basic-information-page.png b/docs/img/basic-information-page.png similarity index 100% rename from docs/assets/basic-information-page.png rename to docs/img/basic-information-page.png diff --git a/docs/assets/bot-token.png b/docs/img/bot-token.png similarity index 100% rename from docs/assets/bot-token.png rename to docs/img/bot-token.png 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/index.md b/docs/index.md deleted file mode 100644 index 7694fecc9..000000000 --- a/docs/index.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -permalink: /concepts -redirect_from: - - / -layout: default -lang: en ---- 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/assistant.md b/docs/japanese/concepts/assistant.md new file mode 100644 index 000000000..664108607 --- /dev/null +++ b/docs/japanese/concepts/assistant.md @@ -0,0 +1,227 @@ +# ใ‚จใƒผใ‚ธใ‚งใƒณใƒˆใƒปใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆ + +ใ“ใฎใƒšใƒผใ‚ธใฏใ€Bolt ใ‚’ไฝฟใฃใฆใ‚จใƒผใ‚ธใ‚งใƒณใƒˆใƒปใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใ‚’ๅฎŸ่ฃ…ใ™ใ‚‹ใŸใ‚ใฎๆ–นๆณ•ใ‚’็ดนไป‹ใ—ใพใ™ใ€‚ใ“ใฎๆฉŸ่ƒฝใซ้–ขใ™ใ‚‹ไธ€่ˆฌ็š„ใชๆƒ…ๅ ฑใซใคใ„ใฆใฏใ€[ใ“ใกใ‚‰ใฎใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใƒšใƒผใ‚ธ๏ผˆ่‹ฑ่ชž๏ผ‰](/ai/)ใ‚’ๅ‚็…งใ—ใฆใใ ใ•ใ„ใ€‚ + +ใ“ใฎๆฉŸ่ƒฝใ‚’ๅฎŸ่ฃ…ใ™ใ‚‹ใŸใ‚ใซใฏใ€ใพใš[ใ‚ขใƒ—ใƒชใฎ่จญๅฎš็”ป้ข](https://api.slack.com/apps)ใง **Agents & Assistants** ๆฉŸ่ƒฝใ‚’ๆœ‰ๅŠนใซใ—ใ€**OAuth & Permissions** ใฎใƒšใƒผใ‚ธใง [`assistant:write`](/reference/scopes/assistant.write)ใ€[chat:write](/reference/scopes/chat.write)ใ€[`im:history`](/reference/scopes/im.history) ใ‚’**ใƒœใƒƒใƒˆใฎ**ใ‚นใ‚ณใƒผใƒ—ใซ่ฟฝๅŠ ใ—ใ€**Event Subscriptions** ใฎใƒšใƒผใ‚ธใง [`assistant_thread_started`](/reference/events/assistant_thread_started)ใ€[`assistant_thread_context_changed`](/reference/events/assistant_thread_context_changed)ใ€[`message.im`](/reference/events/message.im) ใ‚คใƒ™ใƒณใƒˆใ‚’ๆœ‰ๅŠนใซใ—ใฆใใ ใ•ใ„ใ€‚ + +ใพใŸใ€ใ“ใฎๆฉŸ่ƒฝใฏ Slack ใฎๆœ‰ๆ–™ใƒ—ใƒฉใƒณใงใฎใฟๅˆฉ็”จๅฏ่ƒฝใงใ™ใ€‚ใ‚‚ใ—้–‹็™บ็”จใฎๆœ‰ๆ–™ใƒ—ใƒฉใƒณใฎใƒฏใƒผใ‚ฏใ‚นใƒšใƒผใ‚นใ‚’ใŠๆŒใกใงใชใ„ๅ ดๅˆใฏใ€[Developer Program](https://api.slack.com/developer-program) ใซๅ‚ๅŠ ใ—ใ€ๅ…จใฆใฎๆœ‰ๆ–™ใƒ—ใƒฉใƒณๅ‘ใ‘ๆฉŸ่ƒฝใ‚’ๅˆฉ็”จๅฏ่ƒฝใชใ‚ตใƒณใƒ‰ใƒœใƒƒใ‚ฏใ‚น็’ฐๅขƒใ‚’ใคใใ‚‹ใ“ใจใŒใงใใพใ™ใ€‚ + +ใƒฆใƒผใ‚ถใƒผใจใฎใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใ‚นใƒฌใƒƒใƒ‰ๅ†…ใงใฎใ‚„ใ‚Šใจใ‚Šใ‚’ๅ‡ฆ็†ใ™ใ‚‹ใซใฏใ€`assistant_thread_started`ใ€`assistant_thread_context_changed`ใ€`message` ใ‚คใƒ™ใƒณใƒˆใฎ `app.event(...)` ใƒชใ‚นใƒŠใƒผใ‚’ไฝฟใ†ใ“ใจใ‚‚ๅฏ่ƒฝใงใ™ใŒใ€Bolt ใฏใ‚ˆใ‚Šใ‚ทใƒณใƒ—ใƒซใชใ‚ขใƒ—ใƒญใƒผใƒใ‚’ๆไพ›ใ—ใฆใ„ใพใ™ใ€‚`Assistant` ใ‚คใƒณใ‚นใ‚ฟใƒณใ‚นใ‚’ไฝœใ‚Šใ€ใใ‚Œใซๅฟ…่ฆใชใ‚คใƒ™ใƒณใƒˆใƒชใ‚นใƒŠใƒผใ‚’่ฟฝๅŠ ใ—ใ€ๆœ€ๅพŒใซใ“ใฎใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆ่จญๅฎšใ‚’ `App` ใ‚คใƒณใ‚นใ‚ฟใƒณใ‚นใซๆธกใ™ใ ใ‘ใงใ‚ˆใ„ใฎใงใ™ใ€‚ + +```python +assistant = Assistant() + +# ใƒฆใƒผใ‚ถใƒผใŒใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใ‚นใƒฌใƒƒใƒ‰ใ‚’้–‹ใ„ใŸใจใใซๅ‘ผใณๅ‡บใ•ใ‚Œใพใ™ +@assistant.thread_started +def start_assistant_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts): + # ใƒฆใƒผใ‚ถใƒผใซๅฏพใ—ใฆๆœ€ๅˆใฎ่ฟ”ไฟกใ‚’้€ไฟกใ—ใพใ™ + say(":wave: Hi, how can I help you today?") + + # ใƒ—ใƒญใƒณใƒ—ใƒˆไพ‹ใ‚’้€ใ‚‹ใฎใฏๅฟ…้ ˆใงใฏใ‚ใ‚Šใพใ›ใ‚“ + set_suggested_prompts( + prompts=[ + # ใ‚‚ใ—ใƒ—ใƒญใƒณใƒ—ใƒˆใŒ้•ทใ„ๅ ดๅˆใฏ {"title": "่กจ็คบใ™ใ‚‹็Ÿญใ„ใƒฉใƒ™ใƒซ", "message": "ๅฎŒๅ…จใชใƒ—ใƒญใƒณใƒ—ใƒˆ"} ใ‚’ไฝฟใ†ใ“ใจใŒใงใใพใ™ + "What does SLACK stand for?", + "When Slack was released?", + ], + ) + +# ใƒฆใƒผใ‚ถใƒผใŒใ‚นใƒฌใƒƒใƒ‰ๅ†…ใง่ฟ”ไฟกใ—ใŸใจใใซๅ‘ผใณๅ‡บใ•ใ‚Œใพใ™ +@assistant.user_message +def respond_in_assistant_thread( + payload: dict, + logger: logging.Logger, + context: BoltContext, + set_status: SetStatus, + say: Say, + client: WebClient, +): + try: + # ใƒฆใƒผใ‚ถใƒผใซใ“ใฎbotใŒใƒชใ‚ฏใ‚จใ‚นใƒˆใ‚’ๅ—ไฟกใ—ใฆไฝœๆฅญไธญใงใ‚ใ‚‹ใ“ใจใ‚’ไผใˆใพใ™ + set_status("is typing...") + + # ไผš่ฉฑใฎๅฑฅๆญดใ‚’ๅ–ๅพ—ใ—ใพใ™ + replies_in_thread = client.conversations_replies( + channel=context.channel_id, + ts=context.thread_ts, + oldest=context.thread_ts, + limit=10, + ) + messages_in_thread: List[Dict[str, str]] = [] + for message in replies_in_thread["messages"]: + role = "user" if message.get("bot_id") is None else "assistant" + messages_in_thread.append({"role": role, "content": message["text"]}) + + # ใƒ—ใƒญใƒณใƒ—ใƒˆใจไผš่ฉฑใฎๅฑฅๆญดใ‚’ LLM ใซๆธกใ—ใพใ™๏ผˆใ“ใฎ call_llm ใฏใ‚ใชใŸ่‡ช่บซใฎใ‚ณใƒผใƒ‰ใงใ™๏ผ‰ + returned_message = call_llm(messages_in_thread) + + # ็ตๆžœใ‚’ใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใ‚นใƒฌใƒƒใƒ‰ใซ้€ไฟกใ—ใพใ™ + say(text=returned_message) + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + # ใ‚จใƒฉใƒผใซใชใฃใŸๅ ดๅˆใฏๅฟ…ใšใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’้€ไฟกใ™ใ‚‹ใ‚ˆใ†ใซใ—ใฆใใ ใ•ใ„ + # ใใ†ใ—ใชใ‹ใฃใŸๅ ดๅˆใ€'is typing...' ใฎ่กจ็คบใฎใพใพใซใชใฃใฆใ—ใพใ„ใ€ใƒฆใƒผใ‚ถใƒผใฏไผš่ฉฑใ‚’็ถšใ‘ใ‚‹ใ“ใจใŒใงใใชใใชใ‚Šใพใ™ + say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + +# ใ“ใฎใƒŸใƒ‰ใƒซใ‚ฆใ‚งใ‚ขใ‚’ Bolt ใ‚ขใƒ—ใƒชใซ่ฟฝๅŠ ใ—ใพใ™ +app.use(assistant) +``` + +ใƒชใ‚นใƒŠใƒผใซๆŒ‡ๅฎšๅฏ่ƒฝใชๅผ•ๆ•ฐใฎไธ€่ฆงใฏ[ใƒขใ‚ธใƒฅใƒผใƒซใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆ](https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html)ใ‚’ๅ‚่€ƒใซใ—ใฆใใ ใ•ใ„ใ€‚ + +ใƒฆใƒผใ‚ถใƒผใŒใƒใƒฃใƒณใƒใƒซใฎๆจชใงใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใ‚นใƒฌใƒƒใƒ‰ใ‚’้–‹ใ„ใŸๅ ดๅˆใ€ใใฎใƒใƒฃใƒณใƒใƒซใฎๆƒ…ๅ ฑใฏใ€ใใฎใ‚นใƒฌใƒƒใƒ‰ใฎ `AssistantThreadContext` ใƒ‡ใƒผใ‚ฟใจใ—ใฆไฟๆŒใ•ใ‚Œใ€ `get_thread_context` ใƒฆใƒผใƒ†ใ‚ฃใƒชใƒ†ใ‚ฃใ‚’ไฝฟใฃใฆใ‚ขใ‚ฏใ‚ปใ‚นใ™ใ‚‹ใ“ใจใŒใงใใพใ™ใ€‚Bolt ใŒใ“ใฎใƒฆใƒผใƒ†ใ‚ฃใƒชใƒ†ใ‚ฃใ‚’ๆไพ›ใ—ใฆใ„ใ‚‹็†็”ฑใฏใ€ๅพŒ็ถšใฎใƒฆใƒผใ‚ถใƒผใƒกใƒƒใ‚ปใƒผใ‚ธๆŠ•็จฟใฎใ‚คใƒ™ใƒณใƒˆใƒšใ‚คใƒญใƒผใƒ‰ใซๆœ€ๆ–ฐใฎใ‚นใƒฌใƒƒใƒ‰ใฎใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆๆƒ…ๅ ฑใฏๅซใพใ‚Œใชใ„ใŸใ‚ใงใ™ใ€‚ใใฎใŸใ‚ใ€ใ‚ขใƒ—ใƒชใฏใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆๆƒ…ๅ ฑใŒๅค‰ๆ›ดใ•ใ‚ŒใŸใ‚ฟใ‚คใƒŸใƒณใ‚ฐใงใใ‚Œใ‚’ไฝ•ใ‚‰ใ‹ใฎๆ–นๆณ•ใงไฟๅญ˜ใ—ใ€ๅพŒ็ถšใฎใƒกใƒƒใ‚ปใƒผใ‚ธใ‚คใƒ™ใƒณใƒˆใฎใƒชใ‚นใƒŠใƒผใ‚ณใƒผใƒ‰ใ‹ใ‚‰ๅ‚็…งใงใใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚ + +ใใฎใƒฆใƒผใ‚ถใƒผใŒใƒใƒฃใƒณใƒใƒซใ‚’ๅˆ‡ใ‚Šๆ›ฟใˆใŸๅ ดๅˆใ€`assistant_thread_context_changed` ใ‚คใƒ™ใƒณใƒˆใŒใ‚ใชใŸใฎใ‚ขใƒ—ใƒชใซ้€ไฟกใ•ใ‚Œใพใ™ใ€‚๏ผˆไธŠ่จ˜ใฎใ‚ณใƒผใƒ‰ไพ‹ใฎใ‚ˆใ†ใซ๏ผ‰็ต„ใฟ่พผใฟใฎ `Assistant` ใƒŸใƒ‰ใƒซใ‚ฆใ‚งใ‚ขใ‚’ใ‚ซใ‚นใ‚ฟใƒ ่จญๅฎšใชใ—ใงๅˆฉ็”จใ—ใฆใ„ใ‚‹ๅ ดๅˆใ€ใ“ใฎๆ›ดๆ–ฐใ•ใ‚ŒใŸใƒใƒฃใƒณใƒใƒซๆƒ…ๅ ฑใฏใ€่‡ชๅ‹•็š„ใซใ“ใฎใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใƒœใƒƒใƒˆใ‹ใ‚‰ใฎๆœ€ๅˆใฎ่ฟ”ไฟกใฎใƒกใƒƒใ‚ปใƒผใ‚ธใƒกใ‚ฟใƒ‡ใƒผใ‚ฟใจใ—ใฆไฟๅญ˜ใ•ใ‚Œใพใ™ใ€‚ใ“ใ‚Œใฏใ€็ต„ใฟ่พผใฟใฎไป•็ต„ใฟใ‚’ไฝฟใ†ๅ ดๅˆใฏใ€ใ“ใฎใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆๆƒ…ๅ ฑใ‚’่‡ชๅ‰ใง็”จๆ„ใ—ใŸใƒ‡ใƒผใ‚ฟใ‚นใƒˆใ‚ขใซไฟๅญ˜ใ™ใ‚‹ๅฟ…่ฆใฏใชใ„ใจใ„ใ†ใ“ใจใงใ™ใ€‚ใ“ใฎ็ต„ใฟ่พผใฟใฎไป•็ต„ใฟใฎๅ”ฏไธ€ใฎ็Ÿญๆ‰€ใฏใ€่ฟฝๅŠ ใฎ Slack API ๅ‘ผใณๅ‡บใ—ใซใ‚ˆใ‚‹ๅ‡ฆ็†ๆ™‚้–“ใฎใ‚ชใƒผใƒใƒผใƒ˜ใƒƒใƒ‰ใงใ™ใ€‚ๅ…ทไฝ“็š„ใซใฏ `get_thread_context` ใ‚’ๅฎŸ่กŒใ—ใŸใจใใซใ€ใ“ใฎไฟๅญ˜ใ•ใ‚ŒใŸใƒกใƒƒใ‚ปใƒผใ‚ธใƒกใ‚ฟใƒ‡ใƒผใ‚ฟใซใ‚ขใ‚ฏใ‚ปใ‚นใ™ใ‚‹ใŸใ‚ใซ `conversations.history` API ใŒๅ‘ผใณๅ‡บใ•ใ‚Œใพใ™ใ€‚ + +ใ“ใฎใƒ‡ใƒผใ‚ฟใ‚’ๅˆฅใฎๅ ดๆ‰€ใซไฟๅญ˜ใ—ใŸใ„ๅ ดๅˆใ€่‡ชๅ‰ใฎ `AssistantThreadContextStore` ๅฎŸ่ฃ…ใ‚’ `Assistant` ใฎใ‚ณใƒณใ‚นใƒˆใƒฉใ‚ฏใ‚ฟใƒผใซๆธกใ™ใ“ใจใŒใงใใพใ™ใ€‚ใƒชใƒ•ใ‚กใƒฌใƒณใ‚นๅฎŸ่ฃ…ใจใ—ใฆใ€`FileAssistantThreadContextStore` ใจใ„ใ†ใƒญใƒผใ‚ซใƒซใƒ•ใ‚กใ‚คใƒซใ‚ทใ‚นใƒ†ใƒ ใ‚’ไฝฟใฃใฆๅฎŸ่ฃ…ใ‚’ๆไพ›ใ—ใฆใ„ใพใ™: + +```python +# ใ“ใ‚Œใฏใ‚ใใพใงไพ‹ใงใ‚ใ‚Šใ€่‡ชๅ‰ใฎใ‚‚ใฎใ‚’ๆธกใ™ใ“ใจใŒใงใใพใ™ +from slack_bolt import FileAssistantThreadContextStore +assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) +``` + +ใ“ใฎใƒชใƒ•ใ‚กใƒฌใƒณใ‚นๅฎŸ่ฃ…ใฏใƒญใƒผใ‚ซใƒซใƒ•ใ‚กใ‚คใƒซใซไพๅญ˜ใ—ใฆใŠใ‚Šใ€ๆœฌ็•ช็’ฐๅขƒใงใฎๅˆฉ็”จใฏๆŽจๅฅจใ—ใพใ›ใ‚“ใ€‚ๆœฌ็•ชใ‚ขใƒ—ใƒชใงใฏ `AssistantThreadContextStore` ใ‚’็ถ™ๆ‰ฟใ—ใŸ่‡ชๅ‰ใฎใ‚ฏใƒฉใ‚นใ‚’ไฝฟใ†ใ‚ˆใ†ใซใ—ใฆใใ ใ•ใ„ใ€‚ + +ๆœ€ๅพŒใซใ€ๅ‹•ไฝœใ™ใ‚‹ๅฎŒๅ…จใชใ‚ตใƒณใƒ—ใƒซใ‚ณใƒผใƒ‰ไพ‹ใ‚’็ขบ่ชใ—ใŸใ„ๅ ดๅˆใฏใ€็งใŸใกใŒ GitHub ไธŠใงๆไพ›ใ—ใฆใ„ใ‚‹[ใ‚ตใƒณใƒ—ใƒซใ‚ขใƒ—ใƒชใฎใƒชใƒใ‚ธใƒˆใƒช](https://github.com/slack-samples/bolt-python-assistant-template)ใ‚’ใƒใ‚งใƒƒใ‚ฏใ—ใฆใฟใฆใใ ใ•ใ„ใ€‚ + +## ใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใ‚นใƒฌใƒƒใƒ‰ใงใฎ Block Kit ใ‚คใƒณใ‚ฟใƒฉใ‚ฏใ‚ทใƒงใƒณ + +ใ‚ˆใ‚Š้ซ˜ๅบฆใชใƒฆใƒผใ‚นใ‚ฑใƒผใ‚นใงใฏใ€ไธŠใฎใ‚ˆใ†ใชใƒ—ใƒญใƒณใƒ—ใƒˆไพ‹ใฎๆๆกˆใงใฏใชใ Block Kit ใฎใƒœใ‚ฟใƒณใชใฉใ‚’ไฝฟใ„ใŸใ„ใจใ„ใ†ๅ ดๅˆใŒใ‚ใ‚‹ใ‹ใ‚‚ใ—ใ‚Œใพใ›ใ‚“ใ€‚ใใ—ใฆใ€ๅพŒ็ถšใฎๅ‡ฆ็†ใฎใŸใ‚ใซ[ๆง‹้€ ๅŒ–ใ•ใ‚ŒใŸใƒกใƒƒใ‚ปใƒผใ‚ธใƒกใ‚ฟใƒ‡ใƒผใ‚ฟ](/messaging/message-metadata/)ใ‚’ๅซใ‚€ใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’้€ไฟกใ—ใŸใ„ใจใ„ใ†ๅ ดๅˆใ‚‚ใ‚ใ‚‹ใงใ—ใ‚‡ใ†ใ€‚ + +ไพ‹ใˆใฐใ€ใ‚ขใƒ—ใƒชใŒๆœ€ๅˆใฎ่ฟ”ไฟกใงใ€Œๅ‚็…งใ—ใฆใ„ใ‚‹ใƒใƒฃใƒณใƒใƒซใ‚’่ฆ็ด„ใ€ใฎใ‚ˆใ†ใชใƒœใ‚ฟใƒณใ‚’่กจ็คบใ—ใ€ใƒฆใƒผใ‚ถใƒผใŒใใ‚Œใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆใ€ใ‚ˆใ‚Š่ฉณ็ดฐใชๆƒ…ๅ ฑ๏ผˆไพ‹๏ผš่ฆ็ด„ใ™ใ‚‹ใƒกใƒƒใ‚ปใƒผใ‚ธๆ•ฐใƒปๆ—ฅๆ•ฐใ€่ฆ็ด„ใฎ็›ฎ็š„ใชใฉ๏ผ‰ใ‚’้€ไฟกใ€ใ‚ขใƒ—ใƒชใŒใใ‚Œใ‚’ๆง‹้€ ๅŒ–ใ•ใ‚ŒใŸใƒกใƒผใ‚ฟใƒ‡ใƒผใ‚ฟใซๆ•ด็†ใ—ใŸไธŠใงใƒชใ‚ฏใ‚จใ‚นใƒˆๅ†…ๅฎนใ‚’ใƒœใƒƒใƒˆใฎใƒกใƒƒใ‚ปใƒผใ‚ธใจใ—ใฆ้€ไฟกใ™ใ‚‹ใ‚ˆใ†ใชใ‚ทใƒŠใƒชใ‚ชใงใ™ใ€‚ + +ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใงใฏใ€ใ‚ขใƒ—ใƒชใฏใใฎใ‚ขใƒ—ใƒช่‡ช่บซใ‹ใ‚‰้€ไฟกใ—ใŸใƒœใƒƒใƒˆใƒกใƒƒใ‚ปใƒผใ‚ธใซๅฟœ็ญ”ใ™ใ‚‹ใ“ใจใฏใงใใพใ›ใ‚“๏ผˆBolt ใซใฏใ‚ใ‚‰ใ‹ใ˜ใ‚็„ก้™ใƒซใƒผใƒ—ใ‚’้˜ฒๆญขใ™ใ‚‹ๅˆถๅพกใŒๅ…ฅใฃใฆใ„ใ‚‹ใŸใ‚๏ผ‰ใ€‚`ignoring_self_assistant_message_events_enabled=False` ใ‚’ `App` ใฎใ‚ณใƒณใ‚นใƒˆใƒฉใ‚ฏใ‚ฟใƒผใซๆธกใ—ใ€`bot_message` ใƒชใ‚นใƒŠใƒผใ‚’ `Assistant` ใƒŸใƒ‰ใƒซใ‚ฆใ‚งใ‚ขใซ่ฟฝๅŠ ใ™ใ‚‹ใจใ€ไธŠ่จ˜ใฎไพ‹ใฎใ‚ˆใ†ใชใƒชใ‚ฏใ‚จใ‚นใƒˆใ‚’ไผใˆใ‚‹ใƒœใƒƒใƒˆใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’ไฝฟใฃใฆๅ‡ฆ็†ใ‚’็ถ™็ถšใ™ใ‚‹ใ“ใจใŒใงใใ‚‹ใ‚ˆใ†ใซใชใ‚Šใพใ™ใ€‚ + +```python +app = App( + token=os.environ["SLACK_BOT_TOKEN"], + # bot message ใ‚’ๅ—ใ‘ๅ–ใ‚‹ใซใฏๅฟ…ใšใ“ใ‚Œใ‚’ๆŒ‡ๅฎšใ—ใฆใใ ใ•ใ„ + ignoring_self_assistant_message_events_enabled=False, +) + +assistant = Assistant() + +# ใƒชใ‚นใƒŠใƒผใซๆŒ‡ๅฎšๅฏ่ƒฝใชๅผ•ๆ•ฐใฎไธ€่ฆงใฏ https://docs.slack.dev/tools/bolt-python/reference/kwargs_injection/args.html ใ‚’ๅ‚็…งใ—ใฆใใ ใ•ใ„ + +@assistant.thread_started +def start_assistant_thread(say: Say): + say( + text=":wave: Hi, how can I help you today?", + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": ":wave: Hi, how can I help you today?"}, + }, + { + "type": "actions", + "elements": [ + # ่ค‡ๆ•ฐใฎใƒœใ‚ฟใƒณใ‚’้…็ฝฎใ™ใ‚‹ใ“ใจใŒๅฏ่ƒฝใงใ™ + { + "type": "button", + "action_id": "assistant-generate-random-numbers", + "text": {"type": "plain_text", "text": "Generate random numbers"}, + "value": "clicked", + }, + ], + }, + ], + ) + +# ไธŠใฎใƒœใ‚ฟใƒณใŒใ‚ฏใƒชใƒƒใ‚ฏใ•ใ‚ŒใŸใจใใซๅฎŸ่กŒใ•ใ‚Œใพใ™ +@app.action("assistant-generate-random-numbers") +def configure_random_number_generation(ack: Ack, client: WebClient, body: dict): + ack() + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "configure_assistant_summarize_channel", + "title": {"type": "plain_text", "text": "My Assistant"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + # ใ‚ขใ‚ทใ‚นใ‚ฟใƒณใƒˆใ‚นใƒฌใƒƒใƒ‰ใฎๆƒ…ๅ ฑใ‚’ app.view ใƒชใ‚นใƒŠใƒผใซๅผ•ใ็ถ™ใŽใพใ™ + "private_metadata": json.dumps( + { + "channel_id": body["channel"]["id"], + "thread_ts": body["message"]["thread_ts"], + } + ), + "blocks": [ + { + "type": "input", + "block_id": "num", + "label": {"type": "plain_text", "text": "# of outputs"}, + # ่‡ช็„ถ่จ€่ชžใฎใƒ†ใ‚ญใ‚นใƒˆใงใฏใชใใ€ใ‚ใ‚‰ใ‹ใ˜ใ‚ๆฑบใ‚ใ‚‰ใ‚ŒใŸๅฝขๅผใฎๅ…ฅๅŠ›ใ‚’ๅ—ใ‘ๅ–ใ‚‹ใ“ใจใŒใงใใพใ™ + "element": { + "type": "static_select", + "action_id": "input", + "placeholder": {"type": "plain_text", "text": "How many numbers do you need?"}, + "options": [ + {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + {"text": {"type": "plain_text", "text": "10"}, "value": "10"}, + {"text": {"type": "plain_text", "text": "20"}, "value": "20"}, + ], + "initial_option": {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + }, + } + ], + }, + ) + +# ไธŠใฎใƒขใƒผใƒ€ใƒซใŒ้€ไฟกใ•ใ‚ŒใŸใจใใซๅฎŸ่กŒใ•ใ‚Œใพใ™ +@app.view("configure_assistant_summarize_channel") +def receive_random_number_generation_details(ack: Ack, client: WebClient, payload: dict): + ack() + num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] + thread = json.loads(payload["private_metadata"]) + + # ๆง‹้€ ๅŒ–ใ•ใ‚ŒใŸๅ…ฅๅŠ›ๆƒ…ๅ ฑใจใจใ‚‚ใซใƒœใƒƒใƒˆใฎใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’้€ไฟกใ—ใพใ™ + # ไปฅไธ‹ใฎ assistant.bot_message ใƒชใ‚นใƒŠใƒผใŒๅ‡ฆ็†ใ‚’็ถ™็ถšใ—ใพใ™ + # ใ“ใฎใƒชใ‚นใƒŠใƒผๅ†…ใงๅ‡ฆ็†ใ—ใŸใ„ๅ ดๅˆใฏใใ‚Œใงใ‚‚ๆง‹ใ„ใพใ›ใ‚“๏ผ + # bot_message ใƒชใ‚นใƒŠใƒผใŒๅฟ…่ฆใชใ„ๅ ดๅˆใฏ ignoring_self_assistant_message_events_enabled=False ใ‚’่จญๅฎšใ™ใ‚‹ๅฟ…่ฆใฏใ‚ใ‚Šใพใ›ใ‚“ + client.chat_postMessage( + channel=thread["channel_id"], + thread_ts=thread["thread_ts"], + text=f"OK, you need {num} numbers. I will generate it shortly!", + metadata={ + "event_type": "assistant-generate-random-numbers", + "event_payload": {"num": int(num)}, + }, + ) + +# ใ“ใฎใ‚ขใƒ—ใƒชใฎใƒœใƒƒใƒˆใƒฆใƒผใ‚ถใƒผใŒใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’้€ไฟกใ—ใŸใจใใซๅฎŸ่กŒใ•ใ‚Œใพใ™ +@assistant.bot_message +def respond_to_bot_messages(logger: logging.Logger, set_status: SetStatus, say: Say, payload: dict): + try: + if payload.get("metadata", {}).get("event_type") == "assistant-generate-random-numbers": + # ไธŠใฎ random-number-generation ใƒชใ‚ฏใ‚จใ‚นใƒˆใ‚’ๅ‡ฆ็†ใ—ใพใ™ + set_status("is generating an array of random numbers...") + time.sleep(1) + nums: Set[str] = set() + num = payload["metadata"]["event_payload"]["num"] + while len(nums) < num: + nums.add(str(random.randint(1, 100))) + say(f"Here you are: {', '.join(nums)}") + else: + # ใใ‚Œไปฅๅค–ใฎใƒ‘ใ‚ฟใƒผใƒณใงใฏไฝ•ใ‚‚ใ—ใพใ›ใ‚“ + # ใ•ใ‚‰ใซไป–ใฎใƒ‘ใ‚ฟใƒผใƒณใ‚’่ฟฝๅŠ ใ™ใ‚‹ๅ ดๅˆใ€ใƒกใƒƒใ‚ปใƒผใ‚ธ้€ไฟกใฎ็„ก้™ใƒซใƒผใƒ—ใ‚’่ตทใ“ใ•ใชใ„ใ‚ˆใ†ๆณจๆ„ใ—ใฆๅฎŸ่ฃ…ใ—ใฆใใ ใ•ใ„ + pass + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + +# ใƒฆใƒผใ‚ถใƒผใŒ่ฟ”ไฟกใ—ใŸใจใใซๅฎŸ่กŒใ•ใ‚Œใพใ™ +@assistant.user_message +def respond_to_user_messages(logger: logging.Logger, set_status: SetStatus, say: Say): + try: + set_status("is typing...") + say("Please use the buttons in the first reply instead :bow:") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + +# ใ“ใฎใƒŸใƒ‰ใƒซใ‚ฆใ‚งใ‚ขใ‚’ Bolt ใ‚ขใƒ—ใƒชใซ่ฟฝๅŠ ใ—ใพใ™ +app.use(assistant) +``` \ No newline at end of file diff --git a/docs/japanese/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..41e6ae5cd --- /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[ใƒˆใƒผใ‚ฏใƒณใฏใƒ‘ใ‚นใƒฏใƒผใƒ‰ใจๅŒๆง˜ใซๅ–ใ‚Šๆ‰ฑใ„ใ€[ๅฎ‰ๅ…จใชๆ–นๆณ•ใงไฟ็ฎกใ—ใฆใใ ใ•ใ„](/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[๐Ÿ”’ ๅ…จใฆใฎใƒˆใƒผใ‚ฏใƒณใฏๅฎ‰ๅ…จใซไฟ็ฎกใ—ใฆใใ ใ•ใ„ใ€‚] + +ๅฐ‘ใชใใจใ‚‚ใƒ‘ใƒ–ใƒชใƒƒใ‚ฏใชใƒใƒผใ‚ธใƒงใƒณ็ฎก็†ใซใƒใ‚งใƒƒใ‚ฏใ‚คใƒณใ™ใ‚‹ใ‚ˆใ†ใชใ“ใจใฏ้ฟใ‘ใ‚‹ในใใงใ—ใ‚‡ใ†ใ€‚ใพใŸใ€ไธŠใซใ‚ใฃใŸไพ‹ใฎใ‚ˆใ†ใซ็’ฐๅขƒๅค‰ๆ•ฐใ‚’ไป‹ใ—ใฆใ‚ขใ‚ฏใ‚ปใ‚นใ™ใ‚‹ใ‚ˆใ†ใซใ—ใฆใใ ใ•ใ„ใ€‚่ฉณ็ดฐใชๆƒ…ๅ ฑใฏ [ใ‚ขใƒ—ใƒชใฎใ‚ปใ‚ญใƒฅใƒชใƒ†ใ‚ฃใฎใƒ™ใ‚นใƒˆใƒ—ใƒฉใ‚ฏใƒ†ใ‚ฃใ‚น](/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/jp.md b/docs/jp.md deleted file mode 100644 index 387668f76..000000000 --- a/docs/jp.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -permalink: ja-jp/concepts -redirect_from: - - /jp - - /ja-jp -layout: default -lang: ja-jp ---- 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..b8a6da68f --- /dev/null +++ b/docs/reference/adapter/asgi/base_handler.html @@ -0,0 +1,210 @@ + + + + + + +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) -> Dict[str, str]:
    +        while True:
    +            lifespan = await receive()
    +            if lifespan["type"] == "lifespan.startup":
    +                """Do something before startup"""
    +                return {"type": "lifespan.startup.complete"}
    +            if lifespan["type"] == "lifespan.shutdown":
    +                """Do something before shutdown"""
    +                return {"type": "lifespan.shutdown.complete"}
    +
    +    async def __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 send(await self._handle_lifespan(receive))
    +            return
    +        raise TypeError(f"Unsupported scope type: {scope['type']!r}")
    +
    +
    +

    Subclasses

    + +

    Class variables

    +
    +
    var app :ย Appย |ย 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..86e368f6e --- /dev/null +++ b/docs/reference/adapter/asgi/http_response.html @@ -0,0 +1,258 @@ + + + + + + +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.raw_headers: List[Tuple[bytes, bytes]] = [
    +            (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
    +        ]
    +        self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
    +        self.body: bytes = bytes(body, ENCODING)
    +
    +    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +        return {
    +            "type": "http.response.start",
    +            "status": self.status,
    +            "headers": self.raw_headers,
    +        }
    +
    +    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +        return {
    +            "type": "http.response.body",
    +            "body": self.body,
    +            "more_body": False,
    +        }
    +
    +
    +

    Instance variables

    +
    +
    var body
    +
    +
    + +Expand source code + +
    class AsgiHttpResponse:
    +    __slots__ = ("status", "raw_headers", "body")
    +
    +    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        self.status: int = status
    +        self.raw_headers: List[Tuple[bytes, bytes]] = [
    +            (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
    +        ]
    +        self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
    +        self.body: bytes = bytes(body, ENCODING)
    +
    +    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +        return {
    +            "type": "http.response.start",
    +            "status": self.status,
    +            "headers": self.raw_headers,
    +        }
    +
    +    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +        return {
    +            "type": "http.response.body",
    +            "body": self.body,
    +            "more_body": False,
    +        }
    +
    +
    +
    +
    var raw_headers
    +
    +
    + +Expand source code + +
    class AsgiHttpResponse:
    +    __slots__ = ("status", "raw_headers", "body")
    +
    +    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        self.status: int = status
    +        self.raw_headers: List[Tuple[bytes, bytes]] = [
    +            (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
    +        ]
    +        self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
    +        self.body: bytes = bytes(body, ENCODING)
    +
    +    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +        return {
    +            "type": "http.response.start",
    +            "status": self.status,
    +            "headers": self.raw_headers,
    +        }
    +
    +    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +        return {
    +            "type": "http.response.body",
    +            "body": self.body,
    +            "more_body": False,
    +        }
    +
    +
    +
    +
    var status
    +
    +
    + +Expand source code + +
    class AsgiHttpResponse:
    +    __slots__ = ("status", "raw_headers", "body")
    +
    +    def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        self.status: int = status
    +        self.raw_headers: List[Tuple[bytes, bytes]] = [
    +            (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items()
    +        ]
    +        self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING)))
    +        self.body: bytes = bytes(body, ENCODING)
    +
    +    def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]:
    +        return {
    +            "type": "http.response.start",
    +            "status": self.status,
    +            "headers": self.raw_headers,
    +        }
    +
    +    def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]:
    +        return {
    +            "type": "http.response.body",
    +            "body": self.body,
    +            "more_body": False,
    +        }
    +
    +
    +
    +
    +

    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..f43ab11ef --- /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 = "404"
    +        # Falcon 4.x w/ mypy fails to correctly infer the str type here
    +        resp.body = "The page is not found..."
    +
    +    async def on_post(self, req: Request, resp: Response):
    +        bolt_req = await self._to_bolt_request(req)
    +        bolt_resp = await self.app.async_dispatch(bolt_req)
    +        await self._write_response(bolt_resp, resp)
    +
    +    async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest:
    +        return AsyncBoltRequest(
    +            body=(await req.stream.read(req.content_length or 0)).decode("utf-8"),
    +            query=req.query_string,
    +            headers={k.lower(): v for k, v in req.headers.items()},
    +        )
    +
    +    async def _write_response(self, bolt_resp: BoltResponse, resp: Response):
    +        resp.text = bolt_resp.body
    +        status = HTTPStatus(bolt_resp.status)
    +        resp.status = str(f"{status.value} {status.phrase}")
    +        resp.set_headers(bolt_resp.first_headers_without_set_cookie())
    +        for cookie in bolt_resp.cookies():
    +            for name, c in cookie.items():
    +                expire_value = c.get("expires")
    +                expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None
    +                resp.set_cookie(
    +                    name=name,
    +                    value=c.value,
    +                    expires=expire,
    +                    max_age=c.get("max-age"),
    +                    domain=c.get("domain"),
    +                    path=c.get("path"),
    +                    secure=True,
    +                    http_only=True,
    +                )
    +
    +

    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 = "404"
    +    # Falcon 4.x w/ mypy fails to correctly infer the str type here
    +    resp.body = "The page is not found..."
    +
    +
    +
    +
    +async def on_post(self, req:ย falcon.asgi.request.Request, resp:ย falcon.asgi.response.Response) +
    +
    +
    + +Expand source code + +
    async def on_post(self, req: Request, resp: Response):
    +    bolt_req = await self._to_bolt_request(req)
    +    bolt_resp = await self.app.async_dispatch(bolt_req)
    +    await self._write_response(bolt_resp, resp)
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/adapter/falcon/index.html b/docs/reference/adapter/falcon/index.html new file mode 100644 index 000000000..82a2a57e2 --- /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 = "404"
    +        # Falcon 4.x w/ mypy fails to correctly infer the str type here
    +        resp.body = "The page is not found..."
    +
    +    def on_post(self, req: Request, resp: Response):
    +        bolt_req = self._to_bolt_request(req)
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        self._write_response(bolt_resp, resp)
    +
    +    def _to_bolt_request(self, req: Request) -> BoltRequest:
    +        return BoltRequest(
    +            body=req.stream.read(req.content_length or 0).decode("utf-8"),
    +            query=req.query_string,
    +            headers={k.lower(): v for k, v in req.headers.items()},
    +        )
    +
    +    def _write_response(self, bolt_resp: BoltResponse, resp: Response):
    +        if falcon_version.__version__.startswith("2."):
    +            # 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 = "404"
    +    # Falcon 4.x w/ mypy fails to correctly infer the str type here
    +    resp.body = "The page is not found..."
    +
    +
    +
    +
    +def on_post(self, req:ย falcon.request.Request, resp:ย falcon.response.Response) +
    +
    +
    + +Expand source code + +
    def on_post(self, req: Request, resp: Response):
    +    bolt_req = self._to_bolt_request(req)
    +    bolt_resp = self.app.dispatch(bolt_req)
    +    self._write_response(bolt_resp, resp)
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/adapter/falcon/resource.html b/docs/reference/adapter/falcon/resource.html new file mode 100644 index 000000000..73860adc1 --- /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 = "404"
    +        # Falcon 4.x w/ mypy fails to correctly infer the str type here
    +        resp.body = "The page is not found..."
    +
    +    def on_post(self, req: Request, resp: Response):
    +        bolt_req = self._to_bolt_request(req)
    +        bolt_resp = self.app.dispatch(bolt_req)
    +        self._write_response(bolt_resp, resp)
    +
    +    def _to_bolt_request(self, req: Request) -> BoltRequest:
    +        return BoltRequest(
    +            body=req.stream.read(req.content_length or 0).decode("utf-8"),
    +            query=req.query_string,
    +            headers={k.lower(): v for k, v in req.headers.items()},
    +        )
    +
    +    def _write_response(self, bolt_resp: BoltResponse, resp: Response):
    +        if falcon_version.__version__.startswith("2."):
    +            # 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 = "404"
    +    # Falcon 4.x w/ mypy fails to correctly infer the str type here
    +    resp.body = "The page is not found..."
    +
    +
    +
    +
    +def on_post(self, req:ย falcon.request.Request, resp:ย falcon.response.Response) +
    +
    +
    + +Expand source code + +
    def on_post(self, req: Request, resp: Response):
    +    bolt_req = self._to_bolt_request(req)
    +    bolt_resp = self.app.dispatch(bolt_req)
    +    self._write_response(bolt_resp, resp)
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/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 app :ย Appย |ย 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..d2e300efa --- /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)
    +    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 app :ย App
    +
    +

    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..55d96b054 --- /dev/null +++ b/docs/reference/adapter/socket_mode/internals.html @@ -0,0 +1,125 @@ + + + + + + +slack_bolt.adapter.socket_mode.internals API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.adapter.socket_mode.internals

    +
    +
    +

    Internal functions

    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +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)
    +    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..204499a05 --- /dev/null +++ b/docs/reference/adapter/wsgi/handler.html @@ -0,0 +1,236 @@ + + + + + + +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: Dict[str, Any],
    +        start_response: Callable[[str, List[Tuple[str, str]]], None],
    +    ) -> Iterable[bytes]:
    +        request = WsgiHttpRequest(environ)
    +        if "HTTP" in request.protocol:
    +            response: WsgiHttpResponse = self._get_http_response(
    +                request=request,
    +            )
    +            start_response(response.status, response.get_headers())
    +            return response.get_body()
    +        raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}")
    +
    +

    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..fa845dd93 --- /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:ย Dict[str,ย Any]) +
    +
    +
    + +Expand source code + +
    class WsgiHttpRequest:
    +    """This Class uses the PEP 3333 standard to extract request information
    +    from the WSGI web server running the application
    +
    +    PEP 3333: https://peps.python.org/pep-3333/
    +    """
    +
    +    __slots__ = ("method", "path", "query_string", "protocol", "environ")
    +
    +    def __init__(self, environ: Dict[str, Any]):
    +        self.method: str = environ.get("REQUEST_METHOD", "GET")
    +        self.path: str = environ.get("PATH_INFO", "")
    +        self.query_string: str = environ.get("QUERY_STRING", "")
    +        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
    +        self.environ = environ
    +
    +    def get_headers(self) -> Dict[str, 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", 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: Dict[str, Any]):
    +        self.method: str = environ.get("REQUEST_METHOD", "GET")
    +        self.path: str = environ.get("PATH_INFO", "")
    +        self.query_string: str = environ.get("QUERY_STRING", "")
    +        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
    +        self.environ = environ
    +
    +    def get_headers(self) -> Dict[str, 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", 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: Dict[str, Any]):
    +        self.method: str = environ.get("REQUEST_METHOD", "GET")
    +        self.path: str = environ.get("PATH_INFO", "")
    +        self.query_string: str = environ.get("QUERY_STRING", "")
    +        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
    +        self.environ = environ
    +
    +    def get_headers(self) -> Dict[str, 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", 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: Dict[str, Any]):
    +        self.method: str = environ.get("REQUEST_METHOD", "GET")
    +        self.path: str = environ.get("PATH_INFO", "")
    +        self.query_string: str = environ.get("QUERY_STRING", "")
    +        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
    +        self.environ = environ
    +
    +    def get_headers(self) -> Dict[str, 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", 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: Dict[str, Any]):
    +        self.method: str = environ.get("REQUEST_METHOD", "GET")
    +        self.path: str = environ.get("PATH_INFO", "")
    +        self.query_string: str = environ.get("QUERY_STRING", "")
    +        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
    +        self.environ = environ
    +
    +    def get_headers(self) -> Dict[str, 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", 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: Dict[str, Any]):
    +        self.method: str = environ.get("REQUEST_METHOD", "GET")
    +        self.path: str = environ.get("PATH_INFO", "")
    +        self.query_string: str = environ.get("QUERY_STRING", "")
    +        self.protocol: str = environ.get("SERVER_PROTOCOL", "")
    +        self.environ = environ
    +
    +    def get_headers(self) -> Dict[str, 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", 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", 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..da7dc33f0 --- /dev/null +++ b/docs/reference/adapter/wsgi/http_response.html @@ -0,0 +1,197 @@ + + + + + + +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]]ย =ย {}, 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: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        _status = HTTPStatus(status)
    +        self.status = f"{_status.value} {_status.phrase}"
    +        self._headers = headers
    +        self._body = bytes(body, ENCODING)
    +
    +    def get_headers(self) -> List[Tuple[str, str]]:
    +        headers: List[Tuple[str, str]] = []
    +        for key, value in self._headers.items():
    +            if key.lower() == "content-length":
    +                continue
    +            headers.append((key, value[0]))
    +
    +        headers.append(("content-length", str(len(self._body))))
    +        return headers
    +
    +    def get_body(self) -> Iterable[bytes]:
    +        return [self._body]
    +
    +

    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: Dict[str, Sequence[str]] = {}, body: str = ""):
    +        _status = HTTPStatus(status)
    +        self.status = f"{_status.value} {_status.phrase}"
    +        self._headers = headers
    +        self._body = bytes(body, ENCODING)
    +
    +    def get_headers(self) -> List[Tuple[str, str]]:
    +        headers: List[Tuple[str, str]] = []
    +        for key, value in self._headers.items():
    +            if key.lower() == "content-length":
    +                continue
    +            headers.append((key, value[0]))
    +
    +        headers.append(("content-length", str(len(self._body))))
    +        return headers
    +
    +    def get_body(self) -> Iterable[bytes]:
    +        return [self._body]
    +
    +
    +
    +
    +

    Methods

    +
    +
    +def get_body(self) โ€‘>ย Iterable[bytes] +
    +
    +
    + +Expand source code + +
    def get_body(self) -> Iterable[bytes]:
    +    return [self._body]
    +
    +
    +
    +
    +def get_headers(self) โ€‘>ย List[Tuple[str,ย str]] +
    +
    +
    + +Expand source code + +
    def get_headers(self) -> List[Tuple[str, str]]:
    +    headers: List[Tuple[str, str]] = []
    +    for key, value in self._headers.items():
    +        if key.lower() == "content-length":
    +            continue
    +        headers.append((key, value[0]))
    +
    +    headers.append(("content-length", str(len(self._body))))
    +    return headers
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/adapter/wsgi/index.html b/docs/reference/adapter/wsgi/index.html new file mode 100644 index 000000000..c3cfafea1 --- /dev/null +++ b/docs/reference/adapter/wsgi/index.html @@ -0,0 +1,263 @@ + + + + + + +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: Dict[str, Any],
    +        start_response: Callable[[str, List[Tuple[str, str]]], None],
    +    ) -> Iterable[bytes]:
    +        request = WsgiHttpRequest(environ)
    +        if "HTTP" in request.protocol:
    +            response: WsgiHttpResponse = self._get_http_response(
    +                request=request,
    +            )
    +            start_response(response.status, response.get_headers())
    +            return response.get_body()
    +        raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}")
    +
    +

    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..bf0d5ee00 --- /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/building-an-app for details.
    +
    +        If you would like to build an OAuth app for enabling the app to run with multiple workspaces,
    +        refer to https://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/building-an-app for details.

    +

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, +refer to https://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_runner :ย ThreadListenerRunner
    +
    +
    + +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_flow :ย OAuthFlowย |ย 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_runner :ย AsyncioListenerRunner
    +
    +
    + +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_flow :ย AsyncOAuthFlowย |ย 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..32e006944 --- /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/building-an-app for details.
    +
    +        If you would like to build an OAuth app for enabling the app to run with multiple workspaces,
    +        refer to https://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/building-an-app for details.

    +

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, +refer to https://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_runner :ย ThreadListenerRunner
    +
    +
    + +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_flow :ย OAuthFlowย |ย 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..3494ec289 --- /dev/null +++ b/docs/reference/async_app.html @@ -0,0 +1,5723 @@ + + + + + + +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 response :ย BoltResponseย |ย 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_runner :ย AsyncioListenerRunner
    +
    +
    + +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_flow :ย AsyncOAuthFlowย |ย 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_store :ย AsyncAssistantThreadContextStoreย |ย 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 ack :ย AsyncAck
    +
    +
    + +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 complete :ย AsyncComplete
    +
    +
    + +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 fail :ย AsyncFail
    +
    +
    + +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_context :ย AsyncGetThreadContextย |ย 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 respond :ย AsyncRespondย |ย 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_context :ย AsyncSaveThreadContextย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def save_thread_context(self) -> Optional[AsyncSaveThreadContext]:
    +    return self.get("save_thread_context")
    +
    +
    +
    +
    prop say :ย AsyncSay
    +
    +
    + +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_stream :ย AsyncSayStreamย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[AsyncSayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    +
    prop set_status :ย AsyncSetStatusย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_status(self) -> Optional[AsyncSetStatus]:
    +    return self.get("set_status")
    +
    +
    +
    +
    prop set_suggested_prompts :ย AsyncSetSuggestedPromptsย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]:
    +    return self.get("set_suggested_prompts")
    +
    +
    +
    +
    prop set_title :ย AsyncSetTitleย |ย 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 context :ย AsyncBoltContext
    +
    +

    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_store :ย AsyncAssistantThreadContextStore
    +
    +

    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_store :ย AsyncAssistantThreadContextStore
    +
    +

    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,
    +        **kwargs,
    +    ) -> AsyncChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return await self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return await self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel :ย strย |ย None
    +
    +

    The type of the None singleton.

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

    The type of the None singleton.

    +
    +
    var recipient_team_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +class AsyncSetStatus +(client:ย slack_sdk.web.async_client.AsyncWebClient,
    channel_id:ย str,
    thread_ts:ย str)
    +
    +
    +
    + +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,
    +        **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,
    +            **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)
    +
    +
    +
    + +Expand source code + +
    class AsyncSetSuggestedPrompts:
    +    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,
    +        prompts: Sequence[Union[str, Dict[str, str]]],
    +        title: 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=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
    +
    +

    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 context :ย AsyncBoltContext
    +
    +

    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 context :ย BoltContext
    +
    +

    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..6eac3724d --- /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:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    user_id:ย strย |ย Noneย =ย None,
    user:ย strย |ย Noneย =ย None,
    user_token:ย strย |ย Noneย =ย None,
    user_scopes:ย strย |ย Sequence[str]ย |ย Noneย =ย None)
    +
    +
    +
    + +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:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    user_scopes:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    auth_test_response:ย slack_sdk.web.slack_response.SlackResponseย |ย ForwardRef('AsyncSlackResponse'),
    user_auth_test_response:ย slack_sdk.web.slack_response.SlackResponseย |ย ForwardRef('AsyncSlackResponse')ย |ย Noneย =ย None)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/authorization/index.html b/docs/reference/authorization/index.html new file mode 100644 index 000000000..19de311df --- /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:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    user_id:ย strย |ย Noneย =ย None,
    user:ย strย |ย Noneย =ย None,
    user_token:ย strย |ย Noneย =ย None,
    user_scopes:ย strย |ย Sequence[str]ย |ย Noneย =ย None)
    +
    +
    +
    + +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:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    user_scopes:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    auth_test_response:ย slack_sdk.web.slack_response.SlackResponseย |ย ForwardRef('AsyncSlackResponse'),
    user_auth_test_response:ย slack_sdk.web.slack_response.SlackResponseย |ย ForwardRef('AsyncSlackResponse')ย |ย Noneย =ย None)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/context/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 response :ย BoltResponseย |ย 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 response :ย BoltResponseย |ย 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 response :ย BoltResponseย |ย 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..40db52284 --- /dev/null +++ b/docs/reference/context/assistant/assistant_utilities.html @@ -0,0 +1,309 @@ + + + + + + +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})")
    +
    +    def is_valid(self) -> bool:
    +        return self.channel_id is not None and self.thread_ts is not None
    +
    +    @property
    +    def set_status(self) -> SetStatus:
    +        warnings.warn(
    +            "AssistantUtilities.set_status is deprecated. "
    +            "Use the set_status argument directly in your listener function "
    +            "or access it via context.set_status instead.",
    +            DeprecationWarning,
    +            stacklevel=2,
    +        )
    +        return SetStatus(self.client, self.channel_id, self.thread_ts)
    +
    +    @property
    +    def set_title(self) -> SetTitle:
    +        return SetTitle(self.client, self.channel_id, self.thread_ts)
    +
    +    @property
    +    def set_suggested_prompts(self) -> SetSuggestedPrompts:
    +        return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
    +
    +    @property
    +    def say(self) -> Say:
    +        def build_metadata() -> Optional[dict]:
    +            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_store :ย AssistantThreadContextStore
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts :ย str
    +
    +

    The type of the None singleton.

    +
    +
    +

    Instance variables

    +
    +
    prop get_thread_context :ย GetThreadContext
    +
    +
    + +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_context :ย SaveThreadContext
    +
    +
    + +Expand source code + +
    @property
    +def save_thread_context(self) -> SaveThreadContext:
    +    return SaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    prop say :ย Say
    +
    +
    + +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_status :ย SetStatus
    +
    +
    + +Expand source code + +
    @property
    +def set_status(self) -> SetStatus:
    +    warnings.warn(
    +        "AssistantUtilities.set_status is deprecated. "
    +        "Use the set_status argument directly in your listener function "
    +        "or access it via context.set_status instead.",
    +        DeprecationWarning,
    +        stacklevel=2,
    +    )
    +    return SetStatus(self.client, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    prop set_suggested_prompts :ย SetSuggestedPrompts
    +
    +
    + +Expand source code + +
    @property
    +def set_suggested_prompts(self) -> SetSuggestedPrompts:
    +    return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    prop set_title :ย SetTitle
    +
    +
    + +Expand source code + +
    @property
    +def set_title(self) -> SetTitle:
    +    return SetTitle(self.client, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    +

    Methods

    +
    +
    +def is_valid(self) โ€‘>ย bool +
    +
    +
    + +Expand source code + +
    def is_valid(self) -> bool:
    +    return self.channel_id is not None and self.thread_ts is not None
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + 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..fc77b80cb --- /dev/null +++ b/docs/reference/context/assistant/async_assistant_utilities.html @@ -0,0 +1,303 @@ + + + + + + +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})")
    +
    +    def is_valid(self) -> bool:
    +        return self.channel_id is not None and self.thread_ts is not None
    +
    +    @property
    +    def set_status(self) -> AsyncSetStatus:
    +        warnings.warn(
    +            "AsyncAssistantUtilities.set_status is deprecated. "
    +            "Use the set_status argument directly in your listener function "
    +            "or access it via context.set_status instead.",
    +            DeprecationWarning,
    +            stacklevel=2,
    +        )
    +        return AsyncSetStatus(self.client, self.channel_id, self.thread_ts)
    +
    +    @property
    +    def set_title(self) -> AsyncSetTitle:
    +        return AsyncSetTitle(self.client, self.channel_id, self.thread_ts)
    +
    +    @property
    +    def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts:
    +        return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
    +
    +    @property
    +    def say(self) -> AsyncSay:
    +        return AsyncSay(
    +            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_store :ย AsyncAssistantThreadContextStore
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts :ย str
    +
    +

    The type of the None singleton.

    +
    +
    +

    Instance variables

    +
    +
    prop get_thread_context :ย AsyncGetThreadContext
    +
    +
    + +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_context :ย AsyncSaveThreadContext
    +
    +
    + +Expand source code + +
    @property
    +def save_thread_context(self) -> AsyncSaveThreadContext:
    +    return AsyncSaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    prop say :ย AsyncSay
    +
    +
    + +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_status :ย AsyncSetStatus
    +
    +
    + +Expand source code + +
    @property
    +def set_status(self) -> AsyncSetStatus:
    +    warnings.warn(
    +        "AsyncAssistantUtilities.set_status is deprecated. "
    +        "Use the set_status argument directly in your listener function "
    +        "or access it via context.set_status instead.",
    +        DeprecationWarning,
    +        stacklevel=2,
    +    )
    +    return AsyncSetStatus(self.client, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    prop set_suggested_prompts :ย AsyncSetSuggestedPrompts
    +
    +
    + +Expand source code + +
    @property
    +def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts:
    +    return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    prop set_title :ย AsyncSetTitle
    +
    +
    + +Expand source code + +
    @property
    +def set_title(self) -> AsyncSetTitle:
    +    return AsyncSetTitle(self.client, self.channel_id, self.thread_ts)
    +
    +
    +
    +
    +

    Methods

    +
    +
    +def is_valid(self) โ€‘>ย bool +
    +
    +
    + +Expand source code + +
    def is_valid(self) -> bool:
    +    return self.channel_id is not None and self.thread_ts is not None
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + 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 context :ย AsyncBoltContext
    +
    +

    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 context :ย BoltContext
    +
    +

    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..cbb4e4db6 --- /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/eden.zimbelman/.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 ack :ย AsyncAck
    +
    +
    + +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 complete :ย AsyncComplete
    +
    +
    + +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 fail :ย AsyncFail
    +
    +
    + +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_context :ย AsyncGetThreadContextย |ย 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 respond :ย AsyncRespondย |ย 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_context :ย AsyncSaveThreadContextย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def save_thread_context(self) -> Optional[AsyncSaveThreadContext]:
    +    return self.get("save_thread_context")
    +
    +
    +
    +
    prop say :ย AsyncSay
    +
    +
    + +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_stream :ย AsyncSayStreamย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[AsyncSayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    +
    prop set_status :ย AsyncSetStatusย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_status(self) -> Optional[AsyncSetStatus]:
    +    return self.get("set_status")
    +
    +
    +
    +
    prop set_suggested_prompts :ย AsyncSetSuggestedPromptsย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]:
    +    return self.get("set_suggested_prompts")
    +
    +
    +
    +
    prop set_title :ย AsyncSetTitleย |ย 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_result :ย AuthorizeResultย |ย 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 ack :ย Ack
    +
    +
    + +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 complete :ย Complete
    +
    +
    + +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 fail :ย Fail
    +
    +
    + +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_context :ย GetThreadContextย |ย 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 respond :ย Respondย |ย 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_context :ย SaveThreadContextย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def save_thread_context(self) -> Optional[SaveThreadContext]:
    +    return self.get("save_thread_context")
    +
    +
    +
    +
    prop say :ย Say
    +
    +
    + +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_stream :ย SayStreamย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[SayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    +
    prop set_status :ย SetStatusย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_status(self) -> Optional[SetStatus]:
    +    return self.get("set_status")
    +
    +
    +
    +
    prop set_suggested_prompts :ย SetSuggestedPromptsย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
    +    return self.get("set_suggested_prompts")
    +
    +
    +
    +
    prop set_title :ย SetTitleย |ย 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_store :ย AsyncAssistantThreadContextStore
    +
    +

    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_store :ย AssistantThreadContextStore
    +
    +

    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_store :ย AssistantThreadContextStore
    +
    +

    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 ack :ย Ack
    +
    +
    + +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 complete :ย Complete
    +
    +
    + +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 fail :ย Fail
    +
    +
    + +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_context :ย GetThreadContextย |ย 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 respond :ย Respondย |ย 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_context :ย SaveThreadContextย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def save_thread_context(self) -> Optional[SaveThreadContext]:
    +    return self.get("save_thread_context")
    +
    +
    +
    +
    prop say :ย Say
    +
    +
    + +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_stream :ย SayStreamย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[SayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    +
    prop set_status :ย SetStatusย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_status(self) -> Optional[SetStatus]:
    +    return self.get("set_status")
    +
    +
    +
    +
    prop set_suggested_prompts :ย SetSuggestedPromptsย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
    +    return self.get("set_suggested_prompts")
    +
    +
    +
    +
    prop set_title :ย SetTitleย |ย 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_store :ย AsyncAssistantThreadContextStore
    +
    +

    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_store :ย AssistantThreadContextStore
    +
    +

    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_store :ย AssistantThreadContextStore
    +
    +

    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..4010b284d --- /dev/null +++ b/docs/reference/context/say_stream/async_say_stream.html @@ -0,0 +1,174 @@ + + + + + + +slack_bolt.context.say_stream.async_say_stream API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.context.say_stream.async_say_stream

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncSayStream +(*,
    client:ย slack_sdk.web.async_client.AsyncWebClient,
    channel:ย strย |ย Noneย =ย None,
    recipient_team_id:ย strย |ย Noneย =ย None,
    recipient_user_id:ย strย |ย Noneย =ย None,
    thread_ts:ย strย |ย Noneย =ย None)
    +
    +
    +
    + +Expand source code + +
    class AsyncSayStream:
    +    client: AsyncWebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: AsyncWebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    async def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> AsyncChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return await self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return await self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel :ย strย |ย None
    +
    +

    The type of the None singleton.

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

    The type of the None singleton.

    +
    +
    var recipient_team_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/context/say_stream/index.html b/docs/reference/context/say_stream/index.html new file mode 100644 index 000000000..645942c72 --- /dev/null +++ b/docs/reference/context/say_stream/index.html @@ -0,0 +1,191 @@ + + + + + + +slack_bolt.context.say_stream API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.context.say_stream

    +
    +
    +
    +
    +

    Sub-modules

    +
    +
    slack_bolt.context.say_stream.async_say_stream
    +
    +
    +
    +
    slack_bolt.context.say_stream.say_stream
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SayStream +(*,
    client:ย slack_sdk.web.client.WebClient,
    channel:ย strย |ย Noneย =ย None,
    recipient_team_id:ย strย |ย Noneย =ย None,
    recipient_user_id:ย strย |ย Noneย =ย None,
    thread_ts:ย strย |ย Noneย =ย None)
    +
    +
    +
    + +Expand source code + +
    class SayStream:
    +    client: WebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: WebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> ChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel :ย strย |ย None
    +
    +

    The type of the None singleton.

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

    The type of the None singleton.

    +
    +
    var recipient_team_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/context/say_stream/say_stream.html b/docs/reference/context/say_stream/say_stream.html new file mode 100644 index 000000000..784a58bbe --- /dev/null +++ b/docs/reference/context/say_stream/say_stream.html @@ -0,0 +1,174 @@ + + + + + + +slack_bolt.context.say_stream.say_stream API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.context.say_stream.say_stream

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SayStream +(*,
    client:ย slack_sdk.web.client.WebClient,
    channel:ย strย |ย Noneย =ย None,
    recipient_team_id:ย strย |ย Noneย =ย None,
    recipient_user_id:ย strย |ย Noneย =ย None,
    thread_ts:ย strย |ย Noneย =ย None)
    +
    +
    +
    + +Expand source code + +
    class SayStream:
    +    client: WebClient
    +    channel: Optional[str]
    +    recipient_team_id: Optional[str]
    +    recipient_user_id: Optional[str]
    +    thread_ts: Optional[str]
    +
    +    def __init__(
    +        self,
    +        *,
    +        client: WebClient,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +    ):
    +        self.client = client
    +        self.channel = channel
    +        self.recipient_team_id = recipient_team_id
    +        self.recipient_user_id = recipient_user_id
    +        self.thread_ts = thread_ts
    +
    +    def __call__(
    +        self,
    +        *,
    +        buffer_size: Optional[int] = None,
    +        channel: Optional[str] = None,
    +        recipient_team_id: Optional[str] = None,
    +        recipient_user_id: Optional[str] = None,
    +        thread_ts: Optional[str] = None,
    +        **kwargs,
    +    ) -> ChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel :ย strย |ย None
    +
    +

    The type of the None singleton.

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

    The type of the None singleton.

    +
    +
    var recipient_team_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/context/set_status/async_set_status.html b/docs/reference/context/set_status/async_set_status.html new file mode 100644 index 000000000..06efd6447 --- /dev/null +++ b/docs/reference/context/set_status/async_set_status.html @@ -0,0 +1,136 @@ + + + + + + +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,
    +        **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,
    +            **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..aa11815e3 --- /dev/null +++ b/docs/reference/context/set_status/index.html @@ -0,0 +1,153 @@ + + + + + + +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,
    +        **kwargs,
    +    ) -> SlackResponse:
    +        return self.client.assistant_threads_setStatus(
    +            channel_id=self.channel_id,
    +            thread_ts=self.thread_ts,
    +            status=status,
    +            loading_messages=loading_messages,
    +            **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..e4d839f64 --- /dev/null +++ b/docs/reference/context/set_status/set_status.html @@ -0,0 +1,136 @@ + + + + + + +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,
    +        **kwargs,
    +    ) -> SlackResponse:
    +        return self.client.assistant_threads_setStatus(
    +            channel_id=self.channel_id,
    +            thread_ts=self.thread_ts,
    +            status=status,
    +            loading_messages=loading_messages,
    +            **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..4feda52ba --- /dev/null +++ b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html @@ -0,0 +1,141 @@ + + + + + + +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)
    +
    +
    +
    + +Expand source code + +
    class AsyncSetSuggestedPrompts:
    +    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,
    +        prompts: Sequence[Union[str, Dict[str, str]]],
    +        title: 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=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
    +
    +

    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..12d864dde --- /dev/null +++ b/docs/reference/context/set_suggested_prompts/index.html @@ -0,0 +1,158 @@ + + + + + + +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) +
    +
    +
    + +Expand source code + +
    class SetSuggestedPrompts:
    +    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,
    +        prompts: Sequence[Union[str, Dict[str, str]]],
    +        title: 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=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
    +
    +

    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..6c0385e57 --- /dev/null +++ b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html @@ -0,0 +1,141 @@ + + + + + + +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) +
    +
    +
    + +Expand source code + +
    class SetSuggestedPrompts:
    +    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,
    +        prompts: Sequence[Union[str, Dict[str, str]]],
    +        title: 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=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
    +
    +

    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..f57d690e9 --- /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:ย ForwardRef('BoltRequest')ย |ย ForwardRef('AsyncBoltRequest'),
    current_response:ย ForwardRef('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..b2d19719d --- /dev/null +++ b/docs/reference/index.html @@ -0,0 +1,6433 @@ + + + + + + +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 response :ย BoltResponseย |ย 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/building-an-app for details.
    +
    +        If you would like to build an OAuth app for enabling the app to run with multiple workspaces,
    +        refer to https://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/building-an-app for details.

    +

    If you would like to build an OAuth app for enabling the app to run with multiple workspaces, +refer to https://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_runner :ย ThreadListenerRunner
    +
    +
    + +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_flow :ย OAuthFlowย |ย 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 ack :ย Ack
    +
    +

    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 complete :ย Complete
    +
    +

    complete() utility function, signals a successful completion of the custom function

    +
    +
    var context :ย BoltContext
    +
    +

    Context data associated with the incoming request

    +
    +
    var event :ย Dict[str,ย Any]ย |ย None
    +
    +

    An alias for payload in an @app.event listener

    +
    +
    var fail :ย Fail
    +
    +

    fail() utility function, signal that the custom function failed to complete

    +
    +
    var get_thread_context :ย GetThreadContextย |ย 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 req :ย BoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var request :ย BoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var resp :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var respond :ย Respond
    +
    +

    respond() utility function, which utilizes the associated response_url

    +
    +
    var response :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var save_thread_context :ย SaveThreadContextย |ย None
    +
    +

    save_thread_context() utility function for AI Agents & Assistants

    +
    +
    var say :ย Say
    +
    +

    say() utility function, which calls chat.postMessage API with the associated channel ID

    +
    +
    var say_stream :ย SayStreamย |ย None
    +
    +

    say_stream() utility function for conversations, AI Agents & Assistants

    +
    +
    var set_status :ย SetStatusย |ย None
    +
    +

    set_status() utility function for AI Agents & Assistants

    +
    +
    var set_suggested_prompts :ย SetSuggestedPromptsย |ย None
    +
    +

    set_suggested_prompts() utility function for AI Agents & Assistants

    +
    +
    var set_title :ย SetTitleย |ย 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_store :ย AssistantThreadContextStoreย |ย 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 ack :ย Ack
    +
    +
    + +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 complete :ย Complete
    +
    +
    + +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 fail :ย Fail
    +
    +
    + +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_context :ย GetThreadContextย |ย 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 respond :ย Respondย |ย 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_context :ย SaveThreadContextย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def save_thread_context(self) -> Optional[SaveThreadContext]:
    +    return self.get("save_thread_context")
    +
    +
    +
    +
    prop say :ย Say
    +
    +
    + +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_stream :ย SayStreamย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def say_stream(self) -> Optional[SayStream]:
    +    return self.get("say_stream")
    +
    +
    +
    +
    prop set_status :ย SetStatusย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_status(self) -> Optional[SetStatus]:
    +    return self.get("set_status")
    +
    +
    +
    +
    prop set_suggested_prompts :ย SetSuggestedPromptsย |ย None
    +
    +
    + +Expand source code + +
    @property
    +def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]:
    +    return self.get("set_suggested_prompts")
    +
    +
    +
    +
    prop set_title :ย SetTitleย |ย 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 context :ย BoltContext
    +
    +

    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/eden.zimbelman/.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_store :ย AssistantThreadContextStore
    +
    +

    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,
    +        **kwargs,
    +    ) -> ChatStream:
    +        """Starts a new chat stream with context."""
    +        channel = channel or self.channel
    +        thread_ts = thread_ts or self.thread_ts
    +        if channel is None:
    +            raise ValueError("say_stream without channel here is unsupported")
    +        if thread_ts is None:
    +            raise ValueError("say_stream without thread_ts here is unsupported")
    +
    +        if buffer_size is not None:
    +            return self.client.chat_stream(
    +                buffer_size=buffer_size,
    +                channel=channel,
    +                recipient_team_id=recipient_team_id or self.recipient_team_id,
    +                recipient_user_id=recipient_user_id or self.recipient_user_id,
    +                thread_ts=thread_ts,
    +                **kwargs,
    +            )
    +        return self.client.chat_stream(
    +            channel=channel,
    +            recipient_team_id=recipient_team_id or self.recipient_team_id,
    +            recipient_user_id=recipient_user_id or self.recipient_user_id,
    +            thread_ts=thread_ts,
    +            **kwargs,
    +        )
    +
    +
    +

    Class variables

    +
    +
    var channel :ย strย |ย None
    +
    +

    The type of the None singleton.

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

    The type of the None singleton.

    +
    +
    var recipient_team_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var recipient_user_id :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    var thread_ts :ย strย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    +
    +
    +class SetStatus +(client:ย slack_sdk.web.client.WebClient, channel_id:ย str, thread_ts:ย str) +
    +
    +
    + +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,
    +        **kwargs,
    +    ) -> SlackResponse:
    +        return self.client.assistant_threads_setStatus(
    +            channel_id=self.channel_id,
    +            thread_ts=self.thread_ts,
    +            status=status,
    +            loading_messages=loading_messages,
    +            **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) +
    +
    +
    + +Expand source code + +
    class SetSuggestedPrompts:
    +    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,
    +        prompts: Sequence[Union[str, Dict[str, str]]],
    +        title: 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=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
    +
    +

    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 ack :ย Ack
    +
    +

    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 complete :ย Complete
    +
    +

    complete() utility function, signals a successful completion of the custom function

    +
    +
    var context :ย BoltContext
    +
    +

    Context data associated with the incoming request

    +
    +
    var event :ย Dict[str,ย Any]ย |ย None
    +
    +

    An alias for payload in an @app.event listener

    +
    +
    var fail :ย Fail
    +
    +

    fail() utility function, signal that the custom function failed to complete

    +
    +
    var get_thread_context :ย GetThreadContextย |ย 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 req :ย BoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var request :ย BoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var resp :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var respond :ย Respond
    +
    +

    respond() utility function, which utilizes the associated response_url

    +
    +
    var response :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var save_thread_context :ย SaveThreadContextย |ย None
    +
    +

    save_thread_context() utility function for AI Agents & Assistants

    +
    +
    var say :ย Say
    +
    +

    say() utility function, which calls chat.postMessage API with the associated channel ID

    +
    +
    var say_stream :ย SayStreamย |ย None
    +
    +

    say_stream() utility function for conversations, AI Agents & Assistants

    +
    +
    var set_status :ย SetStatusย |ย None
    +
    +

    set_status() utility function for AI Agents & Assistants

    +
    +
    var set_suggested_prompts :ย SetSuggestedPromptsย |ย None
    +
    +

    set_suggested_prompts() utility function for AI Agents & Assistants

    +
    +
    var set_title :ย SetTitleย |ย 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 ack :ย AsyncAck
    +
    +

    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 complete :ย AsyncComplete
    +
    +

    complete() utility function, signals a successful completion of the custom function

    +
    +
    var context :ย AsyncBoltContext
    +
    +

    Context data associated with the incoming request

    +
    +
    var event :ย Dict[str,ย Any]ย |ย None
    +
    +

    An alias for payload in an @app.event listener

    +
    +
    var fail :ย AsyncFail
    +
    +

    fail() utility function, signal that the custom function failed to complete

    +
    +
    var get_thread_context :ย AsyncGetThreadContextย |ย 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 req :ย AsyncBoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var request :ย AsyncBoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var resp :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var respond :ย AsyncRespond
    +
    +

    respond() utility function, which utilizes the associated response_url

    +
    +
    var response :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var save_thread_context :ย AsyncSaveThreadContextย |ย None
    +
    +

    save_thread_context() utility function for AI Agents & Assistants

    +
    +
    var say :ย AsyncSay
    +
    +

    say() utility function, which calls chat.postMessage API with the associated channel ID

    +
    +
    var say_stream :ย AsyncSayStreamย |ย None
    +
    +

    say_stream() utility function for AI Agents & Assistants

    +
    +
    var set_status :ย AsyncSetStatusย |ย None
    +
    +

    set_status() utility function for AI Agents & Assistants

    +
    +
    var set_suggested_prompts :ย AsyncSetSuggestedPromptsย |ย None
    +
    +

    set_suggested_prompts() utility function for AI Agents & Assistants

    +
    +
    var set_title :ย AsyncSetTitleย |ย 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 ack :ย Ack
    +
    +

    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 complete :ย Complete
    +
    +

    complete() utility function, signals a successful completion of the custom function

    +
    +
    var context :ย BoltContext
    +
    +

    Context data associated with the incoming request

    +
    +
    var event :ย Dict[str,ย Any]ย |ย None
    +
    +

    An alias for payload in an @app.event listener

    +
    +
    var fail :ย Fail
    +
    +

    fail() utility function, signal that the custom function failed to complete

    +
    +
    var get_thread_context :ย GetThreadContextย |ย 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 req :ย BoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var request :ย BoltRequest
    +
    +

    Incoming request from Slack

    +
    +
    var resp :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var respond :ย Respond
    +
    +

    respond() utility function, which utilizes the associated response_url

    +
    +
    var response :ย BoltResponse
    +
    +

    Response representation

    +
    +
    var save_thread_context :ย SaveThreadContextย |ย None
    +
    +

    save_thread_context() utility function for AI Agents & Assistants

    +
    +
    var say :ย Say
    +
    +

    say() utility function, which calls chat.postMessage API with the associated channel ID

    +
    +
    var say_stream :ย SayStreamย |ย None
    +
    +

    say_stream() utility function for conversations, AI Agents & Assistants

    +
    +
    var set_status :ย SetStatusย |ย None
    +
    +

    set_status() utility function for AI Agents & Assistants

    +
    +
    var set_suggested_prompts :ย SetSuggestedPromptsย |ย None
    +
    +

    set_suggested_prompts() utility function for AI Agents & Assistants

    +
    +
    var set_title :ย SetTitleย |ย 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_runner :ย AsyncLazyListenerRunner
    +
    +

    The type of the None singleton.

    +
    +
    var listener_completion_handler :ย AsyncListenerCompletionHandler
    +
    +

    The type of the None singleton.

    +
    +
    var listener_error_handler :ย AsyncListenerErrorHandler
    +
    +

    The type of the None singleton.

    +
    +
    var listener_start_handler :ย AsyncListenerStartHandler
    +
    +

    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_runner :ย LazyListenerRunner
    +
    +

    The type of the None singleton.

    +
    +
    var listener_completion_handler :ย ListenerCompletionHandler
    +
    +

    The type of the None singleton.

    +
    +
    var listener_error_handler :ย ListenerErrorHandler
    +
    +

    The type of the None singleton.

    +
    +
    var listener_executor :ย concurrent.futures._base.Executor
    +
    +

    The type of the None singleton.

    +
    +
    var listener_start_handler :ย ListenerStartHandler
    +
    +

    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..1072e6479 --- /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ย |ย ForwardRef('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ย |ย ForwardRef('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_store :ย AssistantThreadContextStoreย |ย 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_store :ย AsyncAssistantThreadContextStoreย |ย 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_store :ย AssistantThreadContextStoreย |ย 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..1ddea9222 --- /dev/null +++ b/docs/reference/middleware/async_builtins.html @@ -0,0 +1,506 @@ + + + + + + +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 not None:
    +            if is_assistant_event(req.body):
    +                assistant = AsyncAssistantUtilities(
    +                    payload=event,
    +                    context=req.context,
    +                    thread_context_store=self.thread_context_store,
    +                )
    +                req.context["say"] = assistant.say
    +                req.context["set_title"] = assistant.set_title
    +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
    +                req.context["get_thread_context"] = assistant.get_thread_context
    +                req.context["save_thread_context"] = assistant.save_thread_context
    +
    +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
    +            thread_ts = req.context.thread_ts or event.get("ts")
    +            if req.context.channel_id and thread_ts:
    +                req.context["set_status"] = AsyncSetStatus(
    +                    client=req.context.client,
    +                    channel_id=req.context.channel_id,
    +                    thread_ts=thread_ts,
    +                )
    +                req.context["say_stream"] = AsyncSayStream(
    +                    client=req.context.client,
    +                    channel=req.context.channel_id,
    +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
    +                    recipient_user_id=req.context.user_id,
    +                    thread_ts=thread_ts,
    +                )
    +        return await next()
    +
    +

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

    +

    Ancestors

    + +

    Class variables

    +
    +
    var thread_context_store :ย AsyncAssistantThreadContextStoreย |ย 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..a0f5bdf85 --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html @@ -0,0 +1,155 @@ + + + + + + +slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AsyncAttachingConversationKwargs +(thread_context_store:ย AsyncAssistantThreadContextStoreย |ย Noneย =ย None) +
    +
    +
    + +Expand source code + +
    class AsyncAttachingConversationKwargs(AsyncMiddleware):
    +
    +    thread_context_store: Optional[AsyncAssistantThreadContextStore]
    +
    +    def __init__(self, thread_context_store: Optional[AsyncAssistantThreadContextStore] = None):
    +        self.thread_context_store = thread_context_store
    +
    +    async def async_process(
    +        self,
    +        *,
    +        req: AsyncBoltRequest,
    +        resp: BoltResponse,
    +        next: Callable[[], Awaitable[BoltResponse]],
    +    ) -> Optional[BoltResponse]:
    +        event = to_event(req.body)
    +        if event is not None:
    +            if is_assistant_event(req.body):
    +                assistant = AsyncAssistantUtilities(
    +                    payload=event,
    +                    context=req.context,
    +                    thread_context_store=self.thread_context_store,
    +                )
    +                req.context["say"] = assistant.say
    +                req.context["set_title"] = assistant.set_title
    +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
    +                req.context["get_thread_context"] = assistant.get_thread_context
    +                req.context["save_thread_context"] = assistant.save_thread_context
    +
    +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
    +            thread_ts = req.context.thread_ts or event.get("ts")
    +            if req.context.channel_id and thread_ts:
    +                req.context["set_status"] = AsyncSetStatus(
    +                    client=req.context.client,
    +                    channel_id=req.context.channel_id,
    +                    thread_ts=thread_ts,
    +                )
    +                req.context["say_stream"] = AsyncSayStream(
    +                    client=req.context.client,
    +                    channel=req.context.channel_id,
    +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
    +                    recipient_user_id=req.context.user_id,
    +                    thread_ts=thread_ts,
    +                )
    +        return await next()
    +
    +

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

    +

    Ancestors

    + +

    Class variables

    +
    +
    var thread_context_store :ย AsyncAssistantThreadContextStoreย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    +

    Inherited members

    + +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html b/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html new file mode 100644 index 000000000..8a1911323 --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html @@ -0,0 +1,149 @@ + + + + + + +slack_bolt.middleware.attaching_conversation_kwargs.attaching_conversation_kwargs API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.middleware.attaching_conversation_kwargs.attaching_conversation_kwargs

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AttachingConversationKwargs +(thread_context_store:ย AssistantThreadContextStoreย |ย Noneย =ย None) +
    +
    +
    + +Expand source code + +
    class AttachingConversationKwargs(Middleware):
    +
    +    thread_context_store: Optional[AssistantThreadContextStore]
    +
    +    def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = None):
    +        self.thread_context_store = thread_context_store
    +
    +    def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]:
    +        event = to_event(req.body)
    +        if event is not None:
    +            if is_assistant_event(req.body):
    +                assistant = AssistantUtilities(
    +                    payload=event,
    +                    context=req.context,
    +                    thread_context_store=self.thread_context_store,
    +                )
    +                req.context["say"] = assistant.say
    +                req.context["set_title"] = assistant.set_title
    +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
    +                req.context["get_thread_context"] = assistant.get_thread_context
    +                req.context["save_thread_context"] = assistant.save_thread_context
    +
    +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
    +            thread_ts = req.context.thread_ts or event.get("ts")
    +            if req.context.channel_id and thread_ts:
    +                req.context["set_status"] = SetStatus(
    +                    client=req.context.client,
    +                    channel_id=req.context.channel_id,
    +                    thread_ts=thread_ts,
    +                )
    +                req.context["say_stream"] = SayStream(
    +                    client=req.context.client,
    +                    channel=req.context.channel_id,
    +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
    +                    recipient_user_id=req.context.user_id,
    +                    thread_ts=thread_ts,
    +                )
    +        return next()
    +
    +

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

    +

    Ancestors

    + +

    Class variables

    +
    +
    var thread_context_store :ย AssistantThreadContextStoreย |ย None
    +
    +

    The type of the None singleton.

    +
    +
    +

    Inherited members

    + +
    +
    +
    +
    + +
    + + + diff --git a/docs/reference/middleware/attaching_conversation_kwargs/index.html b/docs/reference/middleware/attaching_conversation_kwargs/index.html new file mode 100644 index 000000000..308a52712 --- /dev/null +++ b/docs/reference/middleware/attaching_conversation_kwargs/index.html @@ -0,0 +1,166 @@ + + + + + + +slack_bolt.middleware.attaching_conversation_kwargs API documentation + + + + + + + + + + + +
    +
    +
    +

    Module slack_bolt.middleware.attaching_conversation_kwargs

    +
    +
    +
    +
    +

    Sub-modules

    +
    +
    slack_bolt.middleware.attaching_conversation_kwargs.async_attaching_conversation_kwargs
    +
    +
    +
    +
    slack_bolt.middleware.attaching_conversation_kwargs.attaching_conversation_kwargs
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class AttachingConversationKwargs +(thread_context_store:ย AssistantThreadContextStoreย |ย Noneย =ย None) +
    +
    +
    + +Expand source code + +
    class AttachingConversationKwargs(Middleware):
    +
    +    thread_context_store: Optional[AssistantThreadContextStore]
    +
    +    def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = None):
    +        self.thread_context_store = thread_context_store
    +
    +    def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]:
    +        event = to_event(req.body)
    +        if event is not None:
    +            if is_assistant_event(req.body):
    +                assistant = AssistantUtilities(
    +                    payload=event,
    +                    context=req.context,
    +                    thread_context_store=self.thread_context_store,
    +                )
    +                req.context["say"] = assistant.say
    +                req.context["set_title"] = assistant.set_title
    +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
    +                req.context["get_thread_context"] = assistant.get_thread_context
    +                req.context["save_thread_context"] = assistant.save_thread_context
    +
    +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
    +            thread_ts = req.context.thread_ts or event.get("ts")
    +            if req.context.channel_id and thread_ts:
    +                req.context["set_status"] = SetStatus(
    +                    client=req.context.client,
    +                    channel_id=req.context.channel_id,
    +                    thread_ts=thread_ts,
    +                )
    +                req.context["say_stream"] = SayStream(
    +                    client=req.context.client,
    +                    channel=req.context.channel_id,
    +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
    +                    recipient_user_id=req.context.user_id,
    +                    thread_ts=thread_ts,
    +                )
    +        return next()
    +
    +

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

    +

    Ancestors

    + +

    Class variables

    +
    +
    var thread_context_store :ย AssistantThreadContextStoreย |ย 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 authorize :ย AsyncAuthorize
    +
    +

    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 authorize :ย Authorize
    +
    +

    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 authorize :ย Authorize
    +
    +

    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..ce2629224 --- /dev/null +++ b/docs/reference/middleware/index.html @@ -0,0 +1,1165 @@ + + + + + + +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 not None:
    +            if is_assistant_event(req.body):
    +                assistant = AssistantUtilities(
    +                    payload=event,
    +                    context=req.context,
    +                    thread_context_store=self.thread_context_store,
    +                )
    +                req.context["say"] = assistant.say
    +                req.context["set_title"] = assistant.set_title
    +                req.context["set_suggested_prompts"] = assistant.set_suggested_prompts
    +                req.context["get_thread_context"] = assistant.get_thread_context
    +                req.context["save_thread_context"] = assistant.save_thread_context
    +
    +            # TODO: in the future we might want to introduce a "proper" extract_ts utility
    +            thread_ts = req.context.thread_ts or event.get("ts")
    +            if req.context.channel_id and thread_ts:
    +                req.context["set_status"] = SetStatus(
    +                    client=req.context.client,
    +                    channel_id=req.context.channel_id,
    +                    thread_ts=thread_ts,
    +                )
    +                req.context["say_stream"] = SayStream(
    +                    client=req.context.client,
    +                    channel=req.context.channel_id,
    +                    recipient_team_id=req.context.team_id or req.context.enterprise_id,
    +                    recipient_user_id=req.context.user_id,
    +                    thread_ts=thread_ts,
    +                )
    +        return next()
    +
    +

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

    +

    Ancestors

    + +

    Class variables

    +
    +
    var thread_context_store :ย AssistantThreadContextStoreย |ย 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 authorize :ย Authorize
    +
    +

    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.verifier = SignatureVerifier(signing_secret=signing_secret)
    +        self.logger = get_bolt_logger(RequestVerification, 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._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" or (body is not None and body.get("ssl_check") == "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})"
    +        )
    +
    +

    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

    + +

    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..5dfd6ed82 --- /dev/null +++ b/docs/reference/middleware/request_verification/index.html @@ -0,0 +1,182 @@ + + + + + + +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.verifier = SignatureVerifier(signing_secret=signing_secret)
    +        self.logger = get_bolt_logger(RequestVerification, 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._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" or (body is not None and body.get("ssl_check") == "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})"
    +        )
    +
    +

    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

    + +

    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..99134110a --- /dev/null +++ b/docs/reference/middleware/request_verification/request_verification.html @@ -0,0 +1,165 @@ + + + + + + +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.verifier = SignatureVerifier(signing_secret=signing_secret)
    +        self.logger = get_bolt_logger(RequestVerification, 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._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" or (body is not None and body.get("ssl_check") == "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})"
    +        )
    +
    +

    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

    + +

    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 settings :ย AsyncOAuthSettings
    +
    +

    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..5e6a543c4 --- /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:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    user_scopes:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    redirect_uri:ย strย |ย Noneย =ย None,
    install_path:ย strย =ย '/slack/install',
    install_page_rendering_enabled:ย boolย =ย True,
    redirect_uri_path:ย strย =ย '/slack/oauth_redirect',
    callback_options:ย AsyncCallbackOptionsย |ย Noneย =ย None,
    success_url:ย strย |ย Noneย =ย None,
    failure_url:ย strย |ย Noneย =ย None,
    authorization_url:ย strย |ย Noneย =ย None,
    installation_store:ย slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStoreย |ย Noneย =ย None,
    installation_store_bot_only:ย boolย =ย False,
    token_rotation_expiration_minutes:ย intย =ย 120,
    user_token_resolution:ย strย =ย 'authed_user',
    state_validation_enabled:ย boolย =ย True,
    state_store:ย slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStoreย |ย Noneย =ย None,
    state_cookie_name:ย strย =ย 'slack-app-oauth-state',
    state_expiration_seconds:ย intย =ย 600,
    logger:ย logging.Loggerย =ย <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
    +
    +
    +
    + +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 authorize :ย AsyncAuthorize
    +
    +

    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_options :ย AsyncCallbackOptionsย |ย 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 settings :ย OAuthSettings
    +
    +

    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 settings :ย OAuthSettings
    +
    +

    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..1eb2ab7dd --- /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:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    user_scopes:ย strย |ย Sequence[str]ย |ย Noneย =ย None,
    redirect_uri:ย strย |ย Noneย =ย None,
    install_path:ย strย =ย '/slack/install',
    install_page_rendering_enabled:ย boolย =ย True,
    redirect_uri_path:ย strย =ย '/slack/oauth_redirect',
    callback_options:ย CallbackOptionsย |ย Noneย =ย None,
    success_url:ย strย |ย Noneย =ย None,
    failure_url:ย strย |ย Noneย =ย None,
    authorization_url:ย strย |ย Noneย =ย None,
    installation_store:ย slack_sdk.oauth.installation_store.installation_store.InstallationStoreย |ย Noneย =ย None,
    installation_store_bot_only:ย boolย =ย False,
    token_rotation_expiration_minutes:ย intย =ย 120,
    user_token_resolution:ย strย =ย 'authed_user',
    state_validation_enabled:ย boolย =ย True,
    state_store:ย slack_sdk.oauth.state_store.state_store.OAuthStateStoreย |ย Noneย =ย None,
    state_cookie_name:ย strย =ย 'slack-app-oauth-state',
    state_expiration_seconds:ย intย =ย 600,
    logger:ย logging.Loggerย =ย <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
    +
    +
    +
    + +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 authorize :ย Authorize
    +
    +

    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_options :ย CallbackOptionsย |ย 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 context :ย AsyncBoltContext
    +
    +

    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 context :ย BoltContext
    +
    +

    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..bd8319183 --- /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"]["team_id"]
    +    if isinstance(payload.get("view"), dict):
    +        return payload["view"]["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..4fe75fd81 --- /dev/null +++ b/docs/reference/request/payload_utils.html @@ -0,0 +1,628 @@ + + + + + + +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_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_event(body):
    +        return (
    +            is_message_event_in_assistant_thread(body)
    +            and 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_message_event_in_assistant_thread(body:ย Dict[str,ย Any]) โ€‘>ย bool +
    +
    +
    + +Expand source code + +
    def is_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
    +    if is_event(body):
    +        return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im"
    +    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_event(body):
    +        return (
    +            is_message_event_in_assistant_thread(body)
    +            and not is_user_message_event_in_assistant_thread(body)
    +            and (
    +                _is_other_message_sub_event(body["event"].get("message"))
    +                or _is_other_message_sub_event(body["event"].get("previous_message"))
    +            )
    +        )
    +    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_event(body):
    +        return (
    +            is_message_event_in_assistant_thread(body)
    +            and body["event"].get("subtype") in (None, "file_share")
    +            and body["event"].get("thread_ts") is not None
    +            and body["event"].get("bot_id") is None
    +        )
    +    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_event(body) and body["event"]["type"] == "message":
    +        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 context :ย BoltContext
    +
    +

    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 edit :ย AsyncListener
    +
    +

    edit listener, which displays a modal in Workflow Builder

    +
    +
    var execute :ย AsyncListener
    +
    +

    execute listener, which processes the step from app execution

    +
    +
    var save :ย AsyncListener
    +
    +

    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 edit :ย Listener
    +
    +

    edit listener, which displays a modal in Workflow Builder

    +
    +
    var execute :ย Listener
    +
    +

    execute listener, which processes step from app execution

    +
    +
    var save :ย Listener
    +
    +

    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 edit :ย Listener
    +
    +

    edit listener, which displays a modal in Workflow Builder

    +
    +
    var execute :ย Listener
    +
    +

    execute listener, which processes step from app execution

    +
    +
    var save :ย Listener
    +
    +

    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/docs/scripts/tutorial_nav.js b/docs/scripts/tutorial_nav.js deleted file mode 100644 index 8ac9d67ae..000000000 --- a/docs/scripts/tutorial_nav.js +++ /dev/null @@ -1,41 +0,0 @@ -var navTag = 'h3'; - -window.addEventListener('DOMContentLoaded', (event) => { - var sections = document.querySelectorAll(navTag); - var navParent = document.querySelector('.tutorial-nav-list'); - - function createNavElement(title, href) { - var navElement = document.createElement('li'); - - var navCircle = document.createElement('div'); - navCircle.setAttribute('class', 'circle ' + href); - - var navAnchor = document.createElement('a'); - navAnchor.setAttribute('href', '#' + href); - navAnchor.innerText = title; - - navElement.appendChild(navCircle); - navElement.appendChild(navAnchor); - - return navElement; - } - - sections.forEach(function(navHeader) { - var newElement = createNavElement(navHeader.innerText, navHeader.id); - navParent.appendChild(newElement); - }) -}); - -window.addEventListener('scroll', (event) => { - var sections = document.querySelectorAll(navTag); - - sections.forEach(function(navHeader) { - var navElement = document.querySelector('.' + navHeader.id); - - if (window.scrollY >= (navHeader.getBoundingClientRect().top + window.pageYOffset - 5)) { - navElement.setAttribute('class', 'circle completed ' + navHeader.id); - } else { - navElement.setAttribute('class', 'circle ' + navHeader.id); - } - }) -}); 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/examples/app.py b/examples/app.py index f8759c799..6e5a10a0f 100644 --- a/examples/app.py +++ b/examples/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/app_authorize.py b/examples/app_authorize.py index 0b81e9b66..843ecd6f6 100644 --- a/examples/app_authorize.py +++ b/examples/app_authorize.py @@ -1,12 +1,5 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging + logging.basicConfig(level=logging.DEBUG) import os @@ -14,6 +7,7 @@ from slack_bolt.authorization import AuthorizeResult from slack_sdk import WebClient + def authorize(enterprise_id, team_id, user_id, client: WebClient, logger): logger.info(f"{enterprise_id},{team_id},{user_id}") # You can implement your own logic here @@ -23,10 +17,9 @@ def authorize(enterprise_id, team_id, user_id, client: WebClient, logger): bot_token=token, ) -app = App( - signing_secret=os.environ["SLACK_SIGNING_SECRET"], - authorize=authorize -) + +app = App(signing_secret=os.environ["SLACK_SIGNING_SECRET"], authorize=authorize) + @app.command("/hello-bolt-python") def hello_command(ack, body): @@ -46,4 +39,4 @@ def event_test(body, say, logger): # pip install slack_bolt # export SLACK_SIGNING_SECRET=*** # export MY_TOKEN=xoxb-*** -# python app.py +# python app_authorize.py diff --git a/examples/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/examples/async_app.py b/examples/async_app.py index c50416a2a..a699dbb29 100644 --- a/examples/async_app.py +++ b/examples/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -39,4 +32,4 @@ async def command(ack, body): # pip install slack_bolt # export SLACK_SIGNING_SECRET=*** # export SLACK_BOT_TOKEN=xoxb-*** -# python app.py +# python async_app.py diff --git a/examples/async_app_authorize.py b/examples/async_app_authorize.py index aee349331..218c3ef8a 100644 --- a/examples/async_app_authorize.py +++ b/examples/async_app_authorize.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -14,6 +7,7 @@ from slack_bolt.authorization import AuthorizeResult from slack_bolt.async_app import AsyncApp + async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient, logger): logger.info(f"{enterprise_id},{team_id},{user_id}") # You can implement your own logic here @@ -24,10 +18,8 @@ async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient, log ) -app = AsyncApp( - signing_secret=os.environ["SLACK_SIGNING_SECRET"], - authorize=authorize -) +app = AsyncApp(signing_secret=os.environ["SLACK_SIGNING_SECRET"], authorize=authorize) + @app.event("app_mention") async def event_test(body, say, logger): diff --git a/examples/aws_chalice/app.py b/examples/aws_chalice/app.py index 59facf400..6c41942f3 100644 --- a/examples/aws_chalice/app.py +++ b/examples/aws_chalice/app.py @@ -19,14 +19,13 @@ def handle_app_mentions(body, say, logger): def respond_to_slack_within_3_seconds(ack): ack("Accepted!") + def say_it(say): time.sleep(5) say("Done!") -bolt_app.command("/hello-bolt-python-chalice")( - ack=respond_to_slack_within_3_seconds, - lazy=[say_it] -) + +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/examples/aws_lambda/.env.oauth_sample b/examples/aws_lambda/.env.oauth_sample index 3de93cf4b..e08635e3d 100644 --- a/examples/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/examples/aws_lambda/.gitignore b/examples/aws_lambda/.gitignore index b44d4ebbd..dac781e7f 100644 --- a/examples/aws_lambda/.gitignore +++ b/examples/aws_lambda/.gitignore @@ -1,2 +1,4 @@ +slack-bolt/ +slack_bolt/ vendor/ -.env \ No newline at end of file +.env diff --git a/examples/aws_lambda/README.md b/examples/aws_lambda/README.md new file mode 100644 index 000000000..49a8f7da2 --- /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/building-an-app) guide. + Ensure you have installed it to a workspace. +4. Ensure you have exported your Slack Bot Token and Slack Signing Secret for your + apps as the environment variables `SLACK_BOT_TOKEN` and + `SLACK_SIGNING_SECRET`, respectively, as per the + [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. +5. You may want to create a dedicated virtual environment for this example app, as + per the "Setting up your project" section of the + [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide. +6. Let's deploy the Lambda! Run `./deploy_lazy.sh`. By default it deploys to the + us-east-1 region in AWS - you can change this at the top of `lazy_aws_lambda_config.yaml` if you wish. +7. Load up AWS Lambda inside the AWS Console - make sure you are in the correct + 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/building-an-app) guide. You do not need to ensure you have installed it to a workspace, as the OAuth flow will provide your app the ability to be installed by anyone. + +1. Remember those S3 buckets we made? You will need the names of these buckets again in the next step. +2. You need many environment variables exported! Specifically the following from api.slack.com/apps + +```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/examples/aws_lambda/aws_lambda.py b/examples/aws_lambda/aws_lambda.py index e5ffc37f2..171de6e2e 100644 --- a/examples/aws_lambda/aws_lambda.py +++ b/examples/aws_lambda/aws_lambda.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging from slack_bolt import App @@ -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/examples/aws_lambda/aws_lambda_config.yaml b/examples/aws_lambda/aws_lambda_config.yaml index 5f158892e..5c7c7a6de 100644 --- a/examples/aws_lambda/aws_lambda_config.yaml +++ b/examples/aws_lambda/aws_lambda_config.yaml @@ -4,7 +4,7 @@ function_name: bolt_py_function handler: aws_lambda.handler description: My first lambda function runtime: python3.8 -# role: lambda_basic_execution +role: bolt_python_lambda_invocation # S3 upload requires appropriate role with s3:PutObject permission # (ex. basic_s3_upload), a destination bucket, and the key prefix @@ -20,12 +20,11 @@ aws_secret_access_key: # timeout: 15 # memory_size: 512 # concurrency: 500 -# -# Experimental Environment variables +# Lambda environment variables environment_variables: - SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} - SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET} # If `tags` is uncommented then tags will be set at creation or update # time. During an update all other tags will be removed except the tags diff --git a/examples/aws_lambda/aws_lambda_oauth.py b/examples/aws_lambda/aws_lambda_oauth.py index f7210dc85..f8fd175d5 100644 --- a/examples/aws_lambda/aws_lambda_oauth.py +++ b/examples/aws_lambda/aws_lambda_oauth.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging from slack_bolt import App @@ -12,7 +5,10 @@ from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow # process_before_response must be True when running on FaaS -app = App(process_before_response=True, oauth_flow=LambdaS3OAuthFlow(),) +app = App( + process_before_response=True, + oauth_flow=LambdaS3OAuthFlow(), +) @app.event("app_mention") @@ -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/examples/aws_lambda/aws_lambda_oauth_config.yaml b/examples/aws_lambda/aws_lambda_oauth_config.yaml index 03df079b7..e5e837566 100644 --- a/examples/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/examples/aws_lambda/deploy.sh b/examples/aws_lambda/deploy.sh index 0a8f13197..54ca3abdc 100755 --- a/examples/aws_lambda/deploy.sh +++ b/examples/aws_lambda/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash rm -rf vendor && mkdir -p vendor/slack_bolt && cp -pr ../../slack_bolt/* vendor/slack_bolt/ -pip install python-lambda -U +pip install git+https://github.com/nficano/python-lambda lambda deploy \ --config-file aws_lambda_config.yaml \ - --requirements requirements.txt \ No newline at end of file + --requirements requirements.txt diff --git a/examples/aws_lambda/deploy_lazy.sh b/examples/aws_lambda/deploy_lazy.sh index 262ac060d..a76c8165f 100755 --- a/examples/aws_lambda/deploy_lazy.sh +++ b/examples/aws_lambda/deploy_lazy.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 +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 \ No newline at end of file + --requirements requirements.txt diff --git a/examples/aws_lambda/deploy_oauth.sh b/examples/aws_lambda/deploy_oauth.sh index 58f448907..266aae0f8 100755 --- a/examples/aws_lambda/deploy_oauth.sh +++ b/examples/aws_lambda/deploy_oauth.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 +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 \ No newline at end of file + --requirements requirements_oauth.txt diff --git a/examples/aws_lambda/lazy_aws_lambda.py b/examples/aws_lambda/lazy_aws_lambda.py index c4bd25498..185e33347 100644 --- a/examples/aws_lambda/lazy_aws_lambda.py +++ b/examples/aws_lambda/lazy_aws_lambda.py @@ -1,12 +1,5 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys -import time - -sys.path.insert(1, "vendor") -# ------------------------------------------------ - import logging +import time from slack_bolt import App from slack_bolt.adapter.aws_lambda import SlackRequestHandler @@ -25,7 +18,7 @@ def log_request(logger, body, next): def respond_to_slack_within_3_seconds(body, ack): - if body.get("text", None) is None: + if body.get("text") is None: ack(f":x: Usage: {command} (description here)") else: title = body["text"] @@ -53,5 +46,5 @@ def handler(event, context): # export SLACK_BOT_TOKEN=xoxb-*** # rm -rf vendor && cp -pr ../../src/* vendor/ -# pip install python-lambda -# lambda deploy --config-file aws_lambda_config.yaml --requirements requirements.txt +# pip install git+https://github.com/nficano/python-lambda +# lambda deploy --config-file lazy_aws_lambda_config.yaml --requirements requirements.txt diff --git a/examples/aws_lambda/lazy_aws_lambda_config.yaml b/examples/aws_lambda/lazy_aws_lambda_config.yaml index abc1654da..a1ee748d3 100644 --- a/examples/aws_lambda/lazy_aws_lambda_config.yaml +++ b/examples/aws_lambda/lazy_aws_lambda_config.yaml @@ -4,8 +4,7 @@ function_name: bolt_py_function handler: lazy_aws_lambda.handler description: My first lambda function runtime: python3.8 -# role: lambda_basic_execution -role: bolt_python_lambda_invocation # AWSLambdaFullAccess +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 @@ -21,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 @@ -37,4 +35,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/examples/bottle/app.py b/examples/bottle/app.py index 20959217a..8cab4d697 100644 --- a/examples/bottle/app.py +++ b/examples/bottle/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/bottle/oauth_app.py b/examples/bottle/oauth_app.py index 63d2c5db4..8d42327c4 100644 --- a/examples/bottle/oauth_app.py +++ b/examples/bottle/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../../src") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.bottle import SlackRequestHandler diff --git a/examples/cherrypy/app.py b/examples/cherrypy/app.py index 42b335b1f..85eb27797 100644 --- a/examples/cherrypy/app.py +++ b/examples/cherrypy/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/cherrypy/oauth_app.py b/examples/cherrypy/oauth_app.py index 180f9b43e..17c697e97 100644 --- a/examples/cherrypy/oauth_app.py +++ b/examples/cherrypy/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../../src") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.cherrypy import SlackRequestHandler diff --git a/examples/dialogs_app.py b/examples/dialogs_app.py index 46265afbc..5f2509b1a 100644 --- a/examples/dialogs_app.py +++ b/examples/dialogs_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -64,7 +57,7 @@ def dialog_submission_or_cancellation(ack: Ack, body: dict): errors = [ { "name": "loc_origin", - "error": "Pickup Location must be longer than 3 characters" + "error": "Pickup Location must be longer than 3 characters", } ] if len(errors) > 0: @@ -116,7 +109,6 @@ def dialog_suggestion(ack): ) - if __name__ == "__main__": app.start(3000) diff --git a/examples/django/.gitignore b/examples/django/.gitignore index ba520ccd8..68712b32b 100644 --- a/examples/django/.gitignore +++ b/examples/django/.gitignore @@ -1 +1,2 @@ -db.sqlite3 \ No newline at end of file +db.sqlite3 +db/ diff --git a/examples/django/README.md b/examples/django/README.md index 538bec95a..ca0460fd1 100644 --- a/examples/django/README.md +++ b/examples/django/README.md @@ -1,8 +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/building-an-app) guide, this is the right one for you. By default, this Django project runs this application. If you want to switch to OAuth flow supported one, modify `myslackapp/urls.py`. + +To run this app, all you need to do are: + +* 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/building-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, + +* Go back to the Slack app configuration page +* Go to "Event Subscriptions" + * 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=*** -export SLACK_BOT_TOKEN=xoxb-*** + +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 -``` \ No newline at end of file +``` + +As you did at [Building an App](https://docs.slack.dev/tools/bolt-python/building-an-app) guide, configure ngrok or something similar to serve a public endpoint. Lastly, + +* Go back to the Slack app configuration page +* Go to "Event Subscriptions" + * 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/examples/django/manage.py b/examples/django/manage.py index c7ccae1c5..cec2fe4e4 100755 --- a/examples/django/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/examples/django/slackapp/__init__.py b/examples/django/myslackapp/__init__.py similarity index 100% rename from examples/django/slackapp/__init__.py rename to examples/django/myslackapp/__init__.py diff --git a/examples/django/myslackapp/asgi.py b/examples/django/myslackapp/asgi.py new file mode 100644 index 000000000..814c38df4 --- /dev/null +++ b/examples/django/myslackapp/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for myslackapp project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") + +application = get_asgi_application() diff --git a/examples/django/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/examples/django/slackapp/wsgi.py b/examples/django/myslackapp/wsgi.py similarity index 57% rename from examples/django/slackapp/wsgi.py rename to examples/django/myslackapp/wsgi.py index efefedc30..4443e81cb 100644 --- a/examples/django/slackapp/wsgi.py +++ b/examples/django/myslackapp/wsgi.py @@ -1,16 +1,16 @@ """ -WSGI config for slackapp project. +WSGI config for myslackapp project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "slackapp.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myslackapp.settings") application = get_wsgi_application() diff --git a/examples/django/mysql-docker-compose.yml b/examples/django/mysql-docker-compose.yml new file mode 100644 index 000000000..e1f543f56 --- /dev/null +++ b/examples/django/mysql-docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.9' +services: + db: + image: mysql:8 + environment: + MYSQL_DATABASE: slackapp + MYSQL_USER: app + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: password + #command: + # - '--wait_timeout=3' + volumes: + - './db:/var/lib/mysql' + ports: + - 33306:3306 + diff --git a/examples/django/slackapp/migrations/__init__.py b/examples/django/oauth_app/__init__.py similarity index 100% rename from examples/django/slackapp/migrations/__init__.py rename to examples/django/oauth_app/__init__.py diff --git a/examples/django/oauth_app/apps.py b/examples/django/oauth_app/apps.py new file mode 100644 index 000000000..34a1577d4 --- /dev/null +++ b/examples/django/oauth_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OauthAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "oauth_app" diff --git a/examples/django/slackapp/migrations/0001_initial.py b/examples/django/oauth_app/migrations/0001_initial.py similarity index 59% rename from examples/django/slackapp/migrations/0001_initial.py rename to examples/django/oauth_app/migrations/0001_initial.py index 84665c287..d5e3113c6 100644 --- a/examples/django/slackapp/migrations/0001_initial.py +++ b/examples/django/oauth_app/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.1 on 2020-09-26 13:49 +# Generated by Django 3.2.3 on 2021-05-24 05:50 from django.db import migrations, models @@ -15,21 +15,24 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), - ("client_id", models.TextField()), - ("app_id", models.TextField()), - ("enterprise_id", models.TextField(null=True)), - ("team_id", models.TextField(null=True)), + ("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.TextField(null=True)), - ("bot_user_id", models.TextField(null=True)), + ("bot_id", models.CharField(max_length=32, null=True)), + ("bot_user_id", models.CharField(max_length=32, null=True)), ("bot_scopes", models.TextField(null=True)), + ("is_enterprise_install", models.BooleanField(null=True)), ("installed_at", models.DateTimeField()), ], ), @@ -38,27 +41,33 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), - ("client_id", models.TextField()), - ("app_id", models.TextField()), - ("enterprise_id", models.TextField(null=True)), - ("team_id", models.TextField(null=True)), + ("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.TextField(null=True)), + ("bot_id", models.CharField(max_length=32, null=True)), ("bot_user_id", models.TextField(null=True)), ("bot_scopes", models.TextField(null=True)), - ("user_id", models.TextField()), + ("user_id", models.CharField(max_length=32)), ("user_token", models.TextField(null=True)), ("user_scopes", models.TextField(null=True)), ("incoming_webhook_url", models.TextField(null=True)), + ("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()), ], ), @@ -67,14 +76,14 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.AutoField( + models.BigAutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), - ("state", models.TextField()), + ("state", models.CharField(max_length=64)), ("expire_at", models.DateTimeField()), ], ), @@ -88,14 +97,14 @@ class Migration(migrations.Migration): "user_id", "installed_at", ], - name="slackapp_sl_client__9b0d3f_idx", + name="oauth_app_s_client__f32bc0_idx", ), ), migrations.AddIndex( model_name="slackbot", index=models.Index( fields=["client_id", "enterprise_id", "team_id", "installed_at"], - name="slackapp_sl_client__d220d6_idx", + name="oauth_app_s_client__fe2514_idx", ), ), ] diff --git a/examples/django/oauth_app/migrations/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 index ec9338fd2..03d2a394a 100644 --- a/examples/django/requirements.txt +++ b/examples/django/requirements.txt @@ -1 +1,2 @@ -Django>=3,<4 \ No newline at end of file +Django>=3.2,<4 +slack-bolt>=1.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/examples/django/slackapp/views.py b/examples/django/simple_app/urls.py similarity index 54% rename from examples/django/slackapp/views.py rename to examples/django/simple_app/urls.py index 5ff1c8a29..dfdd1291e 100644 --- a/examples/django/slackapp/views.py +++ b/examples/django/simple_app/urls.py @@ -1,16 +1,19 @@ -from django.http import HttpRequest -from django.views.decorators.csrf import csrf_exempt +from django.urls import path from slack_bolt.adapter.django import SlackRequestHandler -from .models import app +from .slack_listeners import app handler = SlackRequestHandler(app=app) +from django.http import HttpRequest +from django.views.decorators.csrf import csrf_exempt + @csrf_exempt -def events(request: HttpRequest): +def slack_events_handler(request: HttpRequest): return handler.handle(request) -def oauth(request: HttpRequest): - return handler.handle(request) +urlpatterns = [ + path("slack/events", slack_events_handler, name="slack_events"), +] diff --git a/examples/django/slackapp/apps.py b/examples/django/slackapp/apps.py deleted file mode 100644 index aa8cafd43..000000000 --- a/examples/django/slackapp/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class SlackAppConfig(AppConfig): - name = "slackapp" diff --git a/examples/django/slackapp/models.py b/examples/django/slackapp/models.py deleted file mode 100644 index 8b84beaac..000000000 --- a/examples/django/slackapp/models.py +++ /dev/null @@ -1,184 +0,0 @@ -# ---------------------- -# Database tables -# ---------------------- - -from django.db import models - -class SlackBot(models.Model): - client_id = models.TextField(null=False) - app_id = models.TextField(null=False) - enterprise_id = models.TextField(null=True) - team_id = models.TextField(null=True) - bot_token = models.TextField(null=True) - bot_id = models.TextField(null=True) - bot_user_id = models.TextField(null=True) - bot_scopes = models.TextField(null=True) - installed_at = models.DateTimeField(null=False) - - class Meta: - indexes = [ - models.Index( - fields=["client_id", "enterprise_id", "team_id", "installed_at"] - ), - ] - - -class SlackInstallation(models.Model): - client_id = models.TextField(null=False) - app_id = models.TextField(null=False) - enterprise_id = models.TextField(null=True) - team_id = models.TextField(null=True) - bot_token = models.TextField(null=True) - bot_id = models.TextField(null=True) - bot_user_id = models.TextField(null=True) - bot_scopes = models.TextField(null=True) - user_id = models.TextField(null=False) - user_token = models.TextField(null=True) - user_scopes = models.TextField(null=True) - incoming_webhook_url = models.TextField(null=True) - incoming_webhook_channel_id = models.TextField(null=True) - incoming_webhook_configuration_url = models.TextField(null=True) - installed_at = models.DateTimeField(null=False) - - class Meta: - indexes = [ - models.Index( - fields=[ - "client_id", - "enterprise_id", - "team_id", - "user_id", - "installed_at", - ] - ), - ] - - -class SlackOAuthState(models.Model): - state = models.TextField(null=False) - expire_at = models.DateTimeField(null=False) - - -# ---------------------- -# Bolt store implementations -# ---------------------- - - -from logging import Logger -from typing import Optional -from uuid import uuid4 -from django.db.models import F -from django.utils import timezone -from slack_sdk.oauth import InstallationStore, OAuthStateStore -from slack_sdk.oauth.installation_store import Bot, Installation - - -class DjangoInstallationStore(InstallationStore): - client_id: str - - def __init__( - self, client_id: str, logger: Logger, - ): - self.client_id = client_id - self._logger = logger - - @property - def logger(self) -> Logger: - return self._logger - - def save(self, installation: Installation): - i = installation.to_dict() - i["client_id"] = self.client_id - SlackInstallation(**i).save() - b = installation.to_bot().to_dict() - b["client_id"] = self.client_id - SlackBot(**b).save() - - def find_bot( - self, *, enterprise_id: Optional[str], team_id: Optional[str] - ) -> Optional[Bot]: - rows = ( - SlackBot.objects.filter(enterprise_id=enterprise_id) - .filter(team_id=team_id) - .order_by(F("installed_at").desc())[:1] - ) - if len(rows) > 0: - b = rows[0] - return Bot( - app_id=b.app_id, - enterprise_id=b.enterprise_id, - team_id=b.team_id, - bot_token=b.bot_token, - bot_id=b.bot_id, - bot_user_id=b.bot_user_id, - bot_scopes=b.bot_scopes, - installed_at=b.installed_at.timestamp(), - ) - return None - - -class DjangoOAuthStateStore(OAuthStateStore): - expiration_seconds: int - - def __init__( - self, expiration_seconds: int, logger: Logger, - ): - self.expiration_seconds = expiration_seconds - self._logger = logger - - @property - def logger(self) -> Logger: - return self._logger - - def issue(self) -> str: - state: str = str(uuid4()) - expire_at = timezone.now() + timezone.timedelta(seconds=self.expiration_seconds) - row = SlackOAuthState(state=state, expire_at=expire_at) - row.save() - return state - - def consume(self, state: str) -> bool: - rows = SlackOAuthState.objects.filter(state=state).filter( - expire_at__gte=timezone.now() - ) - if len(rows) > 0: - for row in rows: - row.delete() - return True - return False - -# ---------------------- -# Slack App -# ---------------------- - -import logging -import os -from slack_bolt import App -from slack_bolt.oauth.oauth_settings import OAuthSettings - -logger = logging.getLogger(__name__) -client_id, client_secret, signing_secret = ( - os.environ["SLACK_CLIENT_ID"], - os.environ["SLACK_CLIENT_SECRET"], - os.environ["SLACK_SIGNING_SECRET"], -) - -app = App( - signing_secret=signing_secret, - installation_store=DjangoInstallationStore(client_id=client_id, logger=logger,), - oauth_settings=OAuthSettings( - client_id=client_id, - client_secret=client_secret, - state_store=DjangoOAuthStateStore(expiration_seconds=120, logger=logger,), - ), -) - - -@app.event("app_mention") -def event_test(body, say, logger): - logger.info(body) - say("What's up?") - -@app.command("/hello-bolt-python") -def command(ack): - ack("This is a Django app!") \ No newline at end of file diff --git a/examples/django/slackapp/settings.py b/examples/django/slackapp/settings.py deleted file mode 100644 index 13252519b..000000000 --- a/examples/django/slackapp/settings.py +++ /dev/null @@ -1,129 +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, - }, - "django.db.backends": {"level": "DEBUG",}, - "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", - "slackapp.apps.SlackAppConfig", -] - -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/examples/django/slackapp/urls.py b/examples/django/slackapp/urls.py deleted file mode 100644 index f875bc822..000000000 --- a/examples/django/slackapp/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path("slack/events", views.events, name="handle"), - path("slack/install", views.oauth, name="install"), - path("slack/oauth_redirect", views.oauth, name="oauth_redirect"), -] diff --git a/examples/docker/aiohttp/Dockerfile b/examples/docker/aiohttp/Dockerfile index c1fd13af5..a7b78ed12 100644 --- a/examples/docker/aiohttp/Dockerfile +++ b/examples/docker/aiohttp/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /build/ RUN pip install -U pip && pip install -r requirements.txt FROM python:3.8.5-slim-buster as app -COPY --from=builder /src/ /app/ +COPY --from=builder /build/ /app/ COPY --from=builder /usr/local/lib/ /usr/local/lib/ WORKDIR /app/ COPY *.py /app/ diff --git a/examples/docker/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 index eacd2c57d..c8f8b94e3 100644 --- a/examples/docker/fastapi-gunicorn/Dockerfile +++ b/examples/docker/fastapi-gunicorn/Dockerfile @@ -9,4 +9,5 @@ RUN pip install -U pip && pip install -r requirements.txt # # docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e VARIABLE_NAME="api" -p 80:80 -it your-repo/hello-bolt -# \ No newline at end of file +# or +# docker run -e SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e VARIABLE_NAME="api" -p 3000:80 -it your-repo/hello-bolt diff --git a/examples/events_app.py b/examples/events_app.py index f15fab9d0..459c7cbbe 100644 --- a/examples/events_app.py +++ b/examples/events_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import re import logging @@ -42,6 +35,11 @@ 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) diff --git a/examples/falcon/app.py b/examples/falcon/app.py index 40e32e93d..83bb5e460 100644 --- a/examples/falcon/app.py +++ b/examples/falcon/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import falcon import logging import re @@ -52,14 +45,26 @@ def test_shortcut(ack, client: WebClient, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/falcon/async_app.py b/examples/falcon/async_app.py new file mode 100644 index 000000000..44989d4fa --- /dev/null +++ b/examples/falcon/async_app.py @@ -0,0 +1,100 @@ +import falcon +import logging +import re +from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck +from slack_bolt.adapter.falcon import AsyncSlackAppResource + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): + logger.info(body) + await ack("thanks!") + await respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +app.command(re.compile(r"/hello-bolt-.+"))(test_command) + + +@app.shortcut("test-shortcut") +async def test_shortcut(ack, client, logger, body): + logger.info(body) + await ack() + res = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +async def view_submission(ack, body, logger): + logger.info(body) + await ack() + + +@app.action("a") +async def button_click(logger, action, ack, respond): + logger.info(action) + await ack() + await respond("Here is my response") + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = falcon.asgi.App() +resource = AsyncSlackAppResource(app) +api.add_route("/slack/events", resource) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn --reload -h 0.0.0.0 -p 3000 async_app:api diff --git a/examples/falcon/async_oauth_app.py b/examples/falcon/async_oauth_app.py new file mode 100644 index 000000000..df4d96933 --- /dev/null +++ b/examples/falcon/async_oauth_app.py @@ -0,0 +1,102 @@ +import falcon +import logging +import re +from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck +from slack_bolt.adapter.falcon import AsyncSlackAppResource + +logging.basicConfig(level=logging.DEBUG) +app = AsyncApp() + + +# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"]) +async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond): + logger.info(body) + await ack("thanks!") + await respond( + blocks=[ + { + "type": "section", + "block_id": "b", + "text": { + "type": "mrkdwn", + "text": "You can add a button alongside text in your message. ", + }, + "accessory": { + "type": "button", + "action_id": "a", + "text": {"type": "plain_text", "text": "Button"}, + "value": "click_me_123", + }, + } + ] + ) + + +app.command(re.compile(r"/hello-bolt-.+"))(test_command) + + +@app.shortcut("test-shortcut") +async def test_shortcut(ack, client, logger, body): + logger.info(body) + await ack() + res = await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "view-id", + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, + "blocks": [ + { + "type": "input", + "element": {"type": "plain_text_input"}, + "label": { + "type": "plain_text", + "text": "Label", + }, + } + ], + }, + ) + logger.info(res) + + +@app.view("view-id") +async def view_submission(ack, body, logger): + logger.info(body) + await ack() + + +@app.action("a") +async def button_click(logger, action, ack, respond): + logger.info(action) + await ack() + await respond("Here is my response") + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +api = falcon.asgi.App() +resource = AsyncSlackAppResource(app) +api.add_route("/slack/events", resource) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# uvicorn --reload -h 0.0.0.0 -p 3000 async_oauth_app:api +api.add_route("/slack/install", resource) +api.add_route("/slack/oauth_redirect", resource) diff --git a/examples/falcon/oauth_app.py b/examples/falcon/oauth_app.py index 167003a9c..7d9ad7f1f 100644 --- a/examples/falcon/oauth_app.py +++ b/examples/falcon/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import falcon import logging import re @@ -52,14 +45,26 @@ def test_shortcut(ack, client: WebClient, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/falcon/requirements.txt b/examples/falcon/requirements.txt index 20b4e2937..d72e1e1d0 100644 --- a/examples/falcon/requirements.txt +++ b/examples/falcon/requirements.txt @@ -1,2 +1,3 @@ falcon>=2,<3 -gunicorn>=20,<21 \ No newline at end of file +gunicorn>=20,<21 +uvicorn diff --git a/examples/fastapi/app.py b/examples/fastapi/app.py index f1b4c7782..3bd275ce9 100644 --- a/examples/fastapi/app.py +++ b/examples/fastapi/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.fastapi import SlackRequestHandler diff --git a/examples/fastapi/async_app.py b/examples/fastapi/async_app.py index 5f2a77625..265cb3b3e 100644 --- a/examples/fastapi/async_app.py +++ b/examples/fastapi/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/fastapi/async_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/examples/fastapi/async_oauth_app.py b/examples/fastapi/async_oauth_app.py index 8ba33668a..f6cdbade5 100644 --- a/examples/fastapi/async_oauth_app.py +++ b/examples/fastapi/async_oauth_app.py @@ -1,11 +1,5 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging + logging.basicConfig(level=logging.DEBUG) from slack_bolt.async_app import AsyncApp diff --git a/examples/fastapi/oauth_app.py b/examples/fastapi/oauth_app.py index dccad57ea..1ce71fc59 100644 --- a/examples/fastapi/oauth_app.py +++ b/examples/fastapi/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.fastapi import SlackRequestHandler diff --git a/examples/flask/app.py b/examples/flask/app.py index ce239280d..ec37fddd4 100644 --- a/examples/flask/app.py +++ b/examples/flask/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) diff --git a/examples/flask/oauth_app.py b/examples/flask/oauth_app.py index c20ef5e67..d48391897 100644 --- a/examples/flask/oauth_app.py +++ b/examples/flask/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.flask import SlackRequestHandler diff --git a/examples/getting_started/README.md b/examples/getting_started/README.md index 63875a4dd..5d3c2f61d 100644 --- a/examples/getting_started/README.md +++ b/examples/getting_started/README.md @@ -1,5 +1,5 @@ # Getting Started with โšก๏ธ Bolt for Python -> Slack app example from ๐Ÿ“š [Getting started with Bolt for Python][1] +> Slack app example from ๐Ÿ“š [Building an App with Bolt for Python][1] ## Overview @@ -42,6 +42,6 @@ ngrok http 3000 python3 app.py ``` -[1]: https://slack.dev/bolt-python/tutorial/getting-started -[2]: https://slack.dev/bolt-python/ -[3]: https://slack.dev/bolt-python/tutorial/getting-started#setting-up-events +[1]: https://docs.slack.dev/tools/bolt-python/building-an-app +[2]: https://docs.slack.dev/tools/bolt-python/ +[3]: https://docs.slack.dev/tools/bolt-python/building-an-app#setting-up-events diff --git a/examples/getting_started/app.py b/examples/getting_started/app.py index 23fd2f96d..aa5223d51 100644 --- a/examples/getting_started/app.py +++ b/examples/getting_started/app.py @@ -4,10 +4,13 @@ # Initializes your app with your bot token and signing secret app = App( token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET") + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), ) + # Listens to incoming messages that contain "hello" +# 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 @@ -15,29 +18,25 @@ def message_hello(message, say): blocks=[ { "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Hey there <@{message['user']}>!" - }, + "text": {"type": "mrkdwn", "text": f"Hey there <@{message['user']}>!"}, "accessory": { "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" - }, - "action_id": "button_click" - } + "text": {"type": "plain_text", "text": "Click Me"}, + "action_id": "button_click", + }, } ], - text=f"Hey there <@{message['user']}>!" + text=f"Hey there <@{message['user']}>!", ) + @app.action("button_click") def action_button_click(body, ack, say): # Acknowledge the action ack() say(f"<@{body['user']['id']}> clicked the button") + # Start your app if __name__ == "__main__": - app.start(port=int(os.environ.get("PORT", 3000))) \ No newline at end of file + app.start(port=int(os.environ.get("PORT", 3000))) diff --git a/examples/google_cloud_functions/.env.yaml.oauth-sample b/examples/google_cloud_functions/.env.yaml.oauth-sample new file mode 100644 index 000000000..75ff16f8b --- /dev/null +++ b/examples/google_cloud_functions/.env.yaml.oauth-sample @@ -0,0 +1,4 @@ +SLACK_CLIENT_ID: '1111.222' +SLACK_CLIENT_SECRET: 'xxx' +SLACK_SIGNING_SECRET: 'yyy' +SLACK_SCOPES: 'app_mentions:read,chat:write,commands' diff --git a/examples/google_cloud_functions/.gitignore b/examples/google_cloud_functions/.gitignore index 69748e961..312fe55e1 100644 --- a/examples/google_cloud_functions/.gitignore +++ b/examples/google_cloud_functions/.gitignore @@ -1 +1,2 @@ -.env.yaml \ No newline at end of file +.env.yaml +main.py diff --git a/examples/google_cloud_functions/datastore.py b/examples/google_cloud_functions/datastore.py new file mode 100644 index 000000000..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 index d682b1606..7ebbe8ab2 100644 --- a/examples/google_cloud_functions/requirements.txt +++ b/examples/google_cloud_functions/requirements.txt @@ -1,2 +1,3 @@ Flask>1 -slack_bolt \ No newline at end of file +slack_bolt +google-cloud-datastore>=2.1.0,<3 \ No newline at end of file diff --git a/examples/google_cloud_functions/main.py b/examples/google_cloud_functions/simple_main.py similarity index 69% rename from examples/google_cloud_functions/main.py rename to examples/google_cloud_functions/simple_main.py index 633efb67b..9ff839aca 100644 --- a/examples/google_cloud_functions/main.py +++ b/examples/google_cloud_functions/simple_main.py @@ -22,26 +22,43 @@ def event_test(body, say, logger): # Flask adapter -from slack_bolt.adapter.flask import SlackRequestHandler +from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from flask import Request + handler = SlackRequestHandler(app) + # Cloud Function -def hello_bolt_app(request): +def hello_bolt_app(req: Request): """HTTP Cloud Function. Args: - request (flask.Request): The request object. + req (flask.Request): The request object. Returns: The response text, or any set of values that can be turned into a Response object using `make_response` . """ - return handler.handle(request) + return handler.handle(req) + + +# For local development +# python main.py +if __name__ == "__main__": + from flask import Flask, request + + flask_app = Flask(__name__) + + @flask_app.route("/function", methods=["GET", "POST"]) + def handle_anything(): + return handler.handle(request) + + flask_app.run(port=3000) # Step1: Create a new Slack App: https://api.slack.com/apps -# Bot Token Scopes: chat:write, commands, app_mentions:read +# Bot Token Scopes: app_mentions:read,chat:write,commands # Step2: Set env variables # cp .env.yaml.sample .env.yaml @@ -52,7 +69,8 @@ def hello_bolt_app(request): # gcloud config set project YOUR_PROJECT_NAME # Step4: Deploy a function in the project -# gcloud functions deploy hello_bolt_app --runtime python38 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml +# cp simple_main.py main.py +# gcloud functions deploy hello_bolt_app --runtime python39 --trigger-http --allow-unauthenticated --env-vars-file .env.yaml # gcloud functions describe hello_bolt_app # Step5: Set Request URL diff --git a/examples/heroku/Procfile b/examples/heroku/Procfile index 66be3ed8b..3da739fbe 100644 --- a/examples/heroku/Procfile +++ b/examples/heroku/Procfile @@ -1 +1 @@ -web: gunicorn --bind :$PORT --workers 1 --threads 2 --timeout 0 main:flask_app \ No newline at end of file +web: gunicorn --bind :$PORT --workers 1 --threads 10 --timeout 0 main:flask_app diff --git a/examples/lazy_async_modals_app.py b/examples/lazy_async_modals_app.py index a566cdf56..663d0699a 100644 --- a/examples/lazy_async_modals_app.py +++ b/examples/lazy_async_modals_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import asyncio import logging from slack_bolt.async_app import AsyncApp @@ -52,14 +45,26 @@ async def open_modal(body, client, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -90,15 +95,14 @@ async def open_modal(body, client, logger): app.command("/hello-bolt-python")( - ack=ack_command, lazy=[post_button_message, open_modal], + ack=ack_command, + lazy=[post_button_message, open_modal], ) @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") diff --git a/examples/lazy_modals_app.py b/examples/lazy_modals_app.py index 4de8c7778..f4f22d5e9 100644 --- a/examples/lazy_modals_app.py +++ b/examples/lazy_modals_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging import time @@ -53,14 +46,26 @@ def open_modal(body, client, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -91,15 +96,14 @@ def open_modal(body, client, logger): app.command("/hello-bolt-python")( - ack=ack_command, lazy=[post_button_message, open_modal], + ack=ack_command, + lazy=[post_button_message, open_modal], ) @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") diff --git a/examples/message_events.py b/examples/message_events.py index e732fd3f6..3fd424060 100644 --- a/examples/message_events.py +++ b/examples/message_events.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging import re from typing import Callable @@ -39,7 +32,7 @@ def extract_subtype(body: dict, context: BoltContext, next: Callable): next() -# https://api.slack.com/events/message +# https://docs.slack.dev/reference/events/message/ # Newly posted messages only # or @app.event("message") @app.event({"type": "message", "subtype": None}) @@ -62,20 +55,20 @@ def detect_deletion(say: Say, body: dict): say(f"You've deleted a message: {text}") -# https://api.slack.com/events/message/file_share -# https://api.slack.com/events/message/bot_message +# https://docs.slack.dev/reference/events/message/file_share +# https://docs.slack.dev/reference/events/message/bot_message @app.event( event={"type": "message", "subtype": re.compile("(me_message)|(file_share)")}, middleware=[extract_subtype], ) -def add_reaction( - body: dict, client: WebClient, context: BoltContext, logger: logging.Logger -): +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", + channel=context.channel_id, + timestamp=message_ts, + name="eyes", ) logger.info(f"api_response: {api_response}") diff --git a/examples/modals_app.py b/examples/modals_app.py index 7859028ff..c38f69e7c 100644 --- a/examples/modals_app.py +++ b/examples/modals_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -29,7 +22,10 @@ def handle_command(body, ack, respond, client, logger): { "type": "section", "block_id": "b", - "text": {"type": "mrkdwn", "text": ":white_check_mark: Accepted!",}, + "text": { + "type": "mrkdwn", + "text": ":white_check_mark: Accepted!", + }, } ], ) @@ -58,14 +54,26 @@ def handle_command(body, ack, respond, client, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, }, { "type": "input", @@ -97,9 +105,7 @@ def handle_command(body, 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") @@ -155,7 +161,8 @@ def button_click(ack, body, respond): ) # ephemeral / kwargs respond( - replace_original=False, text=":white_check_mark: Done!", + replace_original=False, + text=":white_check_mark: Done!", ) diff --git a/examples/modals_app_typed.py b/examples/modals_app_typed.py index 62821bb23..b8b709721 100644 --- a/examples/modals_app_typed.py +++ b/examples/modals_app_typed.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from typing import Callable from logging import Logger @@ -30,17 +23,13 @@ @app.middleware # or app.use(log_request) -def log_request( - logger: Logger, body: dict, next: Callable[[], BoltResponse] -) -> BoltResponse: +def log_request(logger: Logger, body: dict, next: Callable[[], BoltResponse]) -> BoltResponse: logger.debug(body) return next() @app.command("/hello-bolt-python") -def handle_command( - body: dict, ack: Ack, respond: Respond, client: WebClient, logger: Logger -) -> None: +def handle_command(body: dict, ack: Ack, respond: Respond, client: WebClient, logger: Logger) -> None: logger.info(body) ack( text="Accepted!", @@ -56,9 +45,7 @@ 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"), @@ -123,7 +110,9 @@ def show_multi_options(ack: Ack) -> None: ), OptionGroup( label=PlainTextObject(text="Group 2"), - options=[Option(text=PlainTextObject(text="Option 1"), value="2-1"),], + options=[ + Option(text=PlainTextObject(text="Option 1"), value="2-1"), + ], ), ] ) @@ -148,7 +137,8 @@ def button_click(ack: Ack, body: dict, respond: Respond) -> None: ) # ephemeral / kwargs respond( - replace_original=False, text=":white_check_mark: Done!", + replace_original=False, + text=":white_check_mark: Done!", ) diff --git a/examples/oauth_app.py b/examples/oauth_app.py index 596b52fad..ac05bde2e 100644 --- a/examples/oauth_app.py +++ b/examples/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from slack_bolt import App @@ -48,14 +41,26 @@ def test_command(body, respond, client, ack, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/oauth_app_settings.py b/examples/oauth_app_settings.py index 0d1e39e81..1877b07fa 100644 --- a/examples/oauth_app_settings.py +++ b/examples/oauth_app_settings.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging import os from slack_bolt import App, BoltResponse @@ -18,7 +11,10 @@ def success(args: SuccessArgs) -> BoltResponse: - return BoltResponse(status=200, body="Thanks!") + # Do anything here ... + # Call the default handler to return HTTP response + return args.default.success(args) + # return BoltResponse(status=200, body="Thanks!") def failure(args: FailureArgs) -> BoltResponse: @@ -71,14 +67,26 @@ def test_command(body, respond, client, ack, logger): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [ { "type": "input", "element": {"type": "plain_text_input"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/oauth_sqlite3_app.py b/examples/oauth_sqlite3_app.py index a62b4f595..2647de531 100644 --- a/examples/oauth_sqlite3_app.py +++ b/examples/oauth_sqlite3_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -12,7 +5,12 @@ 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") @@ -21,6 +19,12 @@ def handle_app_mentions(body, say, logger): 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/examples/pyramid/app.py b/examples/pyramid/app.py index f3befa086..619563453 100644 --- a/examples/pyramid/app.py +++ b/examples/pyramid/app.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - - import logging from slack_bolt import App from slack_bolt.adapter.pyramid.handler import SlackRequestHandler @@ -28,9 +20,7 @@ def event_test(body, 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/examples/pyramid/oauth_app.py b/examples/pyramid/oauth_app.py index e229c85b6..912b7a04b 100644 --- a/examples/pyramid/oauth_app.py +++ b/examples/pyramid/oauth_app.py @@ -1,11 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - - import logging from slack_bolt import App from slack_bolt.adapter.pyramid.handler import SlackRequestHandler @@ -28,18 +20,12 @@ def event_test(body, 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/examples/readme_app.py b/examples/readme_app.py index 6cdbd8ec1..fe81a0904 100644 --- a/examples/readme_app.py +++ b/examples/readme_app.py @@ -16,13 +16,13 @@ def log_request(logger, body, next): return next() -# Events API: https://api.slack.com/events-api +# Events API: https://docs.slack.dev/apis/events-api/ @app.event("app_mention") def event_test(say): say("What's up?") -# Interactivity: https://api.slack.com/interactivity +# Interactivity: https://docs.slack.dev/interactivity/ @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") def open_modal(ack, client, logger, body): @@ -34,14 +34,23 @@ def open_modal(ack, client, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "blocks": [ { "type": "input", "block_id": "b", "element": {"type": "plain_text_input", "action_id": "a"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/readme_async_app.py b/examples/readme_async_app.py index 092dca64b..f11d308a0 100644 --- a/examples/readme_async_app.py +++ b/examples/readme_async_app.py @@ -14,11 +14,13 @@ app = AsyncApp() + @app.command("/hello-bolt-python") async def command(ack, body, respond): await ack() await respond(f"Hi <@{body['user_id']}>!") + # Middleware @app.middleware # or app.use(log_request) async def log_request(logger, body, next): @@ -26,13 +28,13 @@ async def log_request(logger, body, next): return await next() -# Events API: https://api.slack.com/events-api +# Events API: https://docs.slack.dev/apis/events-api/ @app.event("app_mention") async def event_test(say): await say("What's up?") -# Interactivity: https://api.slack.com/interactivity +# Interactivity: https://docs.slack.dev/interactivity/ @app.shortcut("callback-id-here") # @app.command("/hello-bolt-python") async def open_modal(ack, client, logger, body): @@ -44,14 +46,23 @@ async def open_modal(ack, client, logger, body): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "submit": {"type": "plain_text", "text": "Submit",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "submit": { + "type": "plain_text", + "text": "Submit", + }, "blocks": [ { "type": "input", "block_id": "b", "element": {"type": "plain_text_input", "action_id": "a"}, - "label": {"type": "plain_text", "text": "Label",}, + "label": { + "type": "plain_text", + "text": "Label", + }, } ], }, diff --git a/examples/sanic/async_app.py b/examples/sanic/async_app.py index 6a4e8c19c..c10643640 100644 --- a/examples/sanic/async_app.py +++ b/examples/sanic/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler diff --git a/examples/sanic/async_oauth_app.py b/examples/sanic/async_oauth_app.py index cbadb8cab..c65c085c7 100644 --- a/examples/sanic/async_oauth_app.py +++ b/examples/sanic/async_oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import os from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.sanic import AsyncSlackRequestHandler diff --git a/examples/socket_mode.py b/examples/socket_mode.py 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 index 4aca131de..39f9417ce 100644 --- a/examples/sqlalchemy/async_oauth_app.py +++ b/examples/sqlalchemy/async_oauth_app.py @@ -64,12 +64,22 @@ async def async_save(self, installation: Installation): await database.execute(self.bots.insert(), b) async def async_find_bot( - self, *, enterprise_id: Optional[str], team_id: Optional[str] + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + is_enterprise_install: Optional[bool] = False, ) -> Optional[Bot]: c = self.bots.c query = ( self.bots.select() - .where(and_(c.enterprise_id == enterprise_id, c.team_id == team_id)) + .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) ) @@ -120,9 +130,7 @@ 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} - ) + await database.execute(self.oauth_states.insert(), {"state": state, "expire_at": now}) return state async def async_consume(self, state: str) -> bool: @@ -130,14 +138,10 @@ async def async_consume(self, state: str) -> bool: 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()) - ) + 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"]) - ) + await database.execute(self.oauth_states.delete().where(c.id == row["id"])) return True return False except Exception as e: @@ -157,10 +161,14 @@ async def async_consume(self, state: str) -> bool: ) installation_store = AsyncSQLAlchemyInstallationStore( - client_id=client_id, database_url=database_url, logger=logger, + client_id=client_id, + database_url=database_url, + logger=logger, ) oauth_state_store = AsyncSQLAlchemyOAuthStateStore( - expiration_seconds=120, database_url=database_url, logger=logger, + expiration_seconds=120, + database_url=database_url, + logger=logger, ) app = AsyncApp( @@ -168,7 +176,9 @@ async def async_consume(self, state: str) -> bool: signing_secret=signing_secret, installation_store=installation_store, oauth_settings=AsyncOAuthSettings( - client_id=client_id, client_secret=client_secret, state_store=oauth_state_store, + client_id=client_id, + client_secret=client_secret, + state_store=oauth_state_store, ), ) app_handler = AsyncSlackRequestHandler(app) diff --git a/examples/sqlalchemy/oauth_app.py b/examples/sqlalchemy/oauth_app.py index 3c01b78bf..4ad7f8ff3 100644 --- a/examples/sqlalchemy/oauth_app.py +++ b/examples/sqlalchemy/oauth_app.py @@ -25,14 +25,18 @@ engine: Engine = sqlalchemy.create_engine(database_url) installation_store = SQLAlchemyInstallationStore( - client_id=client_id, engine=engine, logger=logger, + client_id=client_id, + engine=engine, + logger=logger, ) oauth_state_store = SQLAlchemyOAuthStateStore( - expiration_seconds=120, engine=engine, logger=logger, + expiration_seconds=120, + engine=engine, + logger=logger, ) try: - engine.execute("select count(*) from bots") + engine.execute("select count(*) from slack_bots") except Exception as e: installation_store.metadata.create_all(engine) oauth_state_store.metadata.create_all(engine) diff --git a/examples/starlette/app.py b/examples/starlette/app.py index 14842c691..82020b324 100644 --- a/examples/starlette/app.py +++ b/examples/starlette/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler @@ -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/examples/starlette/async_app.py b/examples/starlette/async_app.py index 61fc2ee39..2de984755 100644 --- a/examples/starlette/async_app.py +++ b/examples/starlette/async_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler @@ -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/examples/starlette/async_oauth_app.py b/examples/starlette/async_oauth_app.py index 433b2c3e5..9ea92e1a6 100644 --- a/examples/starlette/async_oauth_app.py +++ b/examples/starlette/async_oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.starlette.async_handler import AsyncSlackRequestHandler diff --git a/examples/starlette/oauth_app.py b/examples/starlette/oauth_app.py index 64791adb3..704921298 100644 --- a/examples/starlette/oauth_app.py +++ b/examples/starlette/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler diff --git a/examples/tornado/app.py b/examples/tornado/app.py index a73eeb7ff..ae0dec036 100644 --- a/examples/tornado/app.py +++ b/examples/tornado/app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging logging.basicConfig(level=logging.DEBUG) @@ -16,9 +9,9 @@ @app.middleware # or app.use(log_request) -def log_request(logger, body, next): +def log_request(logger, body, next_): logger.debug(body) - return next() + next_() @app.event("app_mention") 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/examples/tornado/oauth_app.py b/examples/tornado/oauth_app.py index 2ae762eb1..c1072228d 100644 --- a/examples/tornado/oauth_app.py +++ b/examples/tornado/oauth_app.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "../..") -# ------------------------------------------------ - import logging from slack_bolt import App from slack_bolt.adapter.tornado import SlackEventsHandler, SlackOAuthHandler @@ -16,11 +9,11 @@ @app.middleware # or app.use(log_request) def log_request(logger, body, next): logger.debug(body) - return next() + next() @app.event("app_mention") -def event_test(ack, body, say, logger): +def event_test(body, say, logger): logger.info(body) say("What's up?") diff --git a/examples/async_steps_from_apps.py b/examples/workflow_steps/async_steps_from_apps.py similarity index 85% rename from examples/async_steps_from_apps.py rename to examples/workflow_steps/async_steps_from_apps.py index e38e352bb..ed108cf5e 100644 --- a/examples/async_steps_from_apps.py +++ b/examples/workflow_steps/async_steps_from_apps.py @@ -1,15 +1,18 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_bolt.async_app import AsyncApp, AsyncAck -from slack_bolt.workflows.step.async_step import AsyncConfigure, AsyncUpdate, AsyncComplete, AsyncFail +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) @@ -30,7 +33,7 @@ async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { @@ -84,16 +87,18 @@ async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], + "value": state_values["task_description_input"]["task_description"]["value"], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name", }, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", @@ -104,7 +109,7 @@ async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): "type": "text", "label": "Task Author Email", }, - ] + ], ) await ack() @@ -121,9 +126,7 @@ async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, f "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], } ) - user: AsyncSlackResponse = await client.users_lookupByEmail( - email=step["inputs"]["taskAuthorEmail"]["value"] - ) + user: AsyncSlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) user_id = user["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], @@ -151,10 +154,8 @@ async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, f "blocks": blocks, }, ) - except: - await fail(error={ - "message": "Something wrong!" - }) + except Exception as e: + await fail(error={"message": f"Something wrong! (error: {e})"}) app.step( diff --git a/examples/workflow_steps/async_steps_from_apps_decorator.py b/examples/workflow_steps/async_steps_from_apps_decorator.py 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/async_steps_from_apps_primitive.py b/examples/workflow_steps/async_steps_from_apps_primitive.py similarity index 89% rename from examples/async_steps_from_apps_primitive.py rename to examples/workflow_steps/async_steps_from_apps_primitive.py index 3c65addf0..06a2956db 100644 --- a/examples/async_steps_from_apps_primitive.py +++ b/examples/workflow_steps/async_steps_from_apps_primitive.py @@ -1,15 +1,13 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_bolt.async_app import AsyncApp, AsyncAck +################################################################################ +# 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=*** @@ -34,7 +32,7 @@ async def edit(body: dict, ack: AsyncAck, client: AsyncWebClient): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { @@ -93,16 +91,18 @@ async def save(ack: AsyncAck, client: AsyncWebClient, body: dict): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], + "value": state_values["task_description_input"]["task_description"]["value"], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], }, }, "outputs": [ - {"name": "taskName", "type": "text", "label": "Task Name",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", @@ -136,9 +136,7 @@ async def execute(body: dict, client: AsyncWebClient): }, }, ) - user: AsyncSlackResponse = await client.users_lookupByEmail( - email=step["inputs"]["taskAuthorEmail"]["value"] - ) + user: AsyncSlackResponse = await client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) user_id = user["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], diff --git a/examples/steps_from_apps.py b/examples/workflow_steps/steps_from_apps.py similarity index 86% rename from examples/steps_from_apps.py rename to examples/workflow_steps/steps_from_apps.py index ab3921062..b5f591700 100644 --- a/examples/steps_from_apps.py +++ b/examples/workflow_steps/steps_from_apps.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from slack_sdk import WebClient @@ -13,6 +6,11 @@ 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=*** @@ -38,7 +36,7 @@ def edit(ack: Ack, step, configure: Configure): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { @@ -92,16 +90,18 @@ def save(ack: Ack, view: dict, update: Update): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], + "value": state_values["task_description_input"]["task_description"]["value"], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name", }, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", @@ -112,7 +112,7 @@ def save(ack: Ack, view: dict, update: Update): "type": "text", "label": "Task Author Email", }, - ] + ], ) ack() @@ -130,9 +130,7 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): } ) - user: SlackResponse = client.users_lookupByEmail( - email=step["inputs"]["taskAuthorEmail"]["value"] - ) + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) user_id = user["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], @@ -161,9 +159,7 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): }, ) except Exception as err: - fail(error={ - "message": "Something wrong!" - }) + fail(error={"message": "Something wrong!"}) app.step( diff --git a/examples/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/steps_from_apps_primitive.py b/examples/workflow_steps/steps_from_apps_primitive.py similarity index 88% rename from examples/steps_from_apps_primitive.py rename to examples/workflow_steps/steps_from_apps_primitive.py index 9f8889cbc..dd4231ba6 100644 --- a/examples/steps_from_apps_primitive.py +++ b/examples/workflow_steps/steps_from_apps_primitive.py @@ -1,10 +1,3 @@ -# ------------------------------------------------ -# instead of slack_bolt in requirements.txt -import sys - -sys.path.insert(1, "..") -# ------------------------------------------------ - import logging from slack_sdk import WebClient @@ -12,6 +5,11 @@ 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=*** @@ -36,7 +34,7 @@ def edit(body: dict, ack: Ack, client: WebClient): "block_id": "intro-section", "text": { "type": "plain_text", - "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", # noqa: E501 }, }, { @@ -95,16 +93,18 @@ def save(ack: Ack, client: WebClient, body: dict): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], + "value": state_values["task_description_input"]["task_description"]["value"], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], }, }, "outputs": [ - {"name": "taskName", "type": "text", "label": "Task Name",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, { "name": "taskDescription", "type": "text", @@ -138,9 +138,7 @@ def execute(body: dict, client: WebClient): }, }, ) - user: SlackResponse = client.users_lookupByEmail( - email=step["inputs"]["taskAuthorEmail"]["value"] - ) + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) user_id = user["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], 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..88842d0d9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[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/" + +[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.txt b/requirements/adapter.txt new file mode 100644 index 000000000..c19c7713b --- /dev/null +++ b/requirements/adapter.txt @@ -0,0 +1,28 @@ +# pip install -r requirements/adapter.txt +# NOTE: any of async ones requires pip install -r requirements/async.txt too +# used only under slack_bolt/adapter +boto3<=2 +bottle>=0.12,<1 +chalice>=1.28,<2; +cheroot<12 +CherryPy>=18,<19 +Django>=3,<6 +falcon>=2,<5; python_version<"3.11" +falcon>=3.1.1,<5; python_version>="3.11" +fastapi>=0.70.0,<1 +Flask>=1,<4 +Werkzeug>=2,<4 +pyramid>=1,<3 +setuptools<82 # Pinned: Pyramid depends on pkg_resources (deprecated in setuptools 67.5.0, removed in 82+). See: https://github.com/Pylons/pyramid/issues/3731 + +# Sanic and its dependencies +# Note: Sanic imports tracerite with wild card versions +tracerite<1.1.2; python_version<="3.8" # older versions of python are not compatible with tracerite>1.1.2 +sanic>=21,<24; python_version<="3.8" +sanic>=21,<26; python_version>"3.8" + +starlette>=0.19.1,<1 +tornado>=6,<7 +uvicorn<1 # The oldest version can vary among Python runtime versions +gunicorn>=20,<24 +websocket_client>=1.2.3,<2 # Socket Mode 3rd party implementation diff --git a/requirements/adapter_testing.txt b/requirements/adapter_testing.txt new file mode 100644 index 000000000..c497a1f3f --- /dev/null +++ b/requirements/adapter_testing.txt @@ -0,0 +1,5 @@ +# pip install -r requirements/adapter_testing.txt +moto>=3,<6 # For AWS tests +docker>=5,<8 # Used by moto +boddle>=0.2,<0.3 # For Bottle app tests +sanic-testing>=0.7 diff --git a/requirements/async.txt b/requirements/async.txt new file mode 100644 index 000000000..af3e49913 --- /dev/null +++ b/requirements/async.txt @@ -0,0 +1,3 @@ +# pip install -r requirements/async.txt +aiohttp>=3,<4 +websockets<16 diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 000000000..62fdcca2d --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,4 @@ +# pip install -r requirements/testing.txt +-r testing_without_asyncio.txt +-r async.txt +pytest-asyncio<2; diff --git a/requirements/testing_without_asyncio.txt b/requirements/testing_without_asyncio.txt new file mode 100644 index 000000000..441b49f8b --- /dev/null +++ b/requirements/testing_without_asyncio.txt @@ -0,0 +1,3 @@ +# pip install -r requirements/testing_without_asyncio.txt +pytest<8.5 +pytest-cov>=3,<8 diff --git a/requirements/tools.txt b/requirements/tools.txt new file mode 100644 index 000000000..dd13bd614 --- /dev/null +++ b/requirements/tools.txt @@ -0,0 +1,3 @@ +mypy==1.19.1 +flake8==7.3.0 +black==26.3.1 diff --git a/scripts/build_pypi_package.sh b/scripts/build_pypi_package.sh index 773436d23..5806262a6 100755 --- a/scripts/build_pypi_package.sh +++ b/scripts/build_pypi_package.sh @@ -5,8 +5,7 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - python setup.py test && \ - pip install twine wheel && \ + 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_test_pypi.sh old mode 100755 new mode 100644 similarity index 60% rename from scripts/deploy_to_prod_pypi_org.sh rename to scripts/deploy_to_test_pypi.sh index 24c09850f..a6b9c352d --- a/scripts/deploy_to_prod_pypi_org.sh +++ b/scripts/deploy_to_test_pypi.sh @@ -5,9 +5,8 @@ cd ${script_dir}/.. rm -rf ./slack_bolt.egg-info pip install -U pip && \ - python setup.py test && \ - pip install twine wheel && \ + pip install -U twine build && \ rm -rf dist/ build/ slack_bolt.egg-info/ && \ - python setup.py sdist bdist_wheel && \ + python -m build --sdist --wheel && \ twine check dist/* && \ - twine upload dist/* + 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 30bf560f4..000000000 --- a/scripts/deploy_to_test_pypi_org.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -script_dir=`dirname $0` -cd ${script_dir}/.. -rm -rf ./slack_bolt.egg-info - -pip install -U pip && \ - python setup.py test && \ - pip install twine wheel && \ - 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..e73bcdac4 --- /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/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..c3b9fd260 --- /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.txt +pip install -U -r requirements/async.txt +pip install -U pdoc3 +pip install . +rm -rf docs/reference + +pdoc slack_bolt --html -o docs/reference +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..96159c63c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Installs all dependencies of the project +# ./scripts/install.sh + +script_dir=`dirname $0` +cd ${script_dir}/.. +rm -rf ./slack_bolt.egg-info + +# Update pip to prevent warnings +pip install -U pip + +# The package causes a conflict with moto +pip uninstall python-lambda + +pip install -U -e . +pip install -U -r requirements/testing.txt +pip install -U -r requirements/adapter.txt +pip install -U -r requirements/adapter_testing.txt +pip install -U -r requirements/tools.txt + +# To avoid errors due to the old versions of click forced by Chalice +pip install -U pip click diff --git a/scripts/install_all_and_run_tests.sh b/scripts/install_all_and_run_tests.sh index c7eb6c1a6..939e71ffd 100755 --- a/scripts/install_all_and_run_tests.sh +++ b/scripts/install_all_and_run_tests.sh @@ -5,20 +5,19 @@ script_dir=`dirname $0` cd ${script_dir}/.. -rm -rf ./slack_bolt.egg-info -test_target="$1" +test_target="${1:-tests/}" -if [[ $test_target != "" ]] -then - pip install -e ".[testing]" && \ - pip install -e ".[adapter]" && \ - black slack_bolt/ tests/ && \ - pytest $1 -else - pip install -e ".[testing]" && \ - pip install -e ".[adapter]" && \ - black slack_bolt/ tests/ && \ - pytest && \ - pytype slack_bolt/ +# 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..efee01ebc --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# ./scripts/lint.sh + +script_dir=$(dirname $0) +cd ${script_dir}/.. + +if [[ "$1" != "--no-install" ]]; then + pip install -U pip + pip install -U -r requirements/tools.txt +fi + +flake8 slack_bolt/ && flake8 examples/ diff --git a/scripts/run_mypy.sh b/scripts/run_mypy.sh new file mode 100755 index 000000000..27589b348 --- /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.txt + pip install -U -r requirements/adapter.txt + pip install -U -r requirements/tools.txt +fi + +mypy --config-file pyproject.toml diff --git a/scripts/run_pytype.sh b/scripts/run_pytype.sh deleted file mode 100755 index f5424bbf0..000000000 --- a/scripts/run_pytype.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -# ./scripts/run_pytype.sh - -script_dir=$(dirname $0) -cd ${script_dir}/.. -pip install -e ".[adapter]" && pytype slack_bolt/ diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 7170abeb0..d4dc767e3 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -6,22 +6,7 @@ script_dir=`dirname $0` cd ${script_dir}/.. -test_target="$1" -python_version=`python --version | awk '{print $2}'` +test_target="${1:-tests/}" -if [[ $test_target != "" ]] -then - black slack_bolt/ tests/ && \ - pytest $1 -else - if [ ${python_version:0:3} == "3.8" ] - then - # pytype's behavior can be different in older Python versions - black slack_bolt/ tests/ \ - && pytest \ - && pip install -e ".[adapter]" \ - && pytype slack_bolt/ - else - black slack_bolt/ tests/ && pytest - fi -fi +./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 8f35239e4..000000000 --- a/setup.py +++ /dev/null @@ -1,83 +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-cov>=2,<3", - "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=["examples", "integration_tests", "tests", "tests.*",] - ), - include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.0.0b0",], - 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]" - # NOTE: any of async ones requires pip install -e ".[async]" too - "adapter": [ - # used only under src/slack_bolt/adapter - "boto3<=2", - "moto<=2", # For AWS tests - "bottle>=0.12,<1", - "boddle>=0.2,<0.3", # For Bottle app tests - "chalice>=1,<2", - "click>=7,<8", # for chalice - "CherryPy>=18,<19", - "Django>=3,<4", - "falcon>=2,<3", - "fastapi<1", - "Flask>=1,<2", - "pyramid>=1,<2", - "sanic>=20,<21", - "starlette>=0.13,<1", - "requests>=2,<3", # For starlette's TestClient - "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 44ea7d641..d85453950 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -1,11 +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/building-an-app) and look at our [code examples](https://github.com/slackapi/bolt-python/tree/main/examples) to learn how to build apps using Bolt. + +* Website: https://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 .listener import Listener # noqa -from .listener_matcher import CustomListenerMatcher # noqa -from .request import BoltRequest # noqa -from .response import BoltResponse # noqa +from .app import App +from .context import BoltContext +from .context.ack import Ack +from .context.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 6d4cb9715..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] ) @@ -31,9 +33,15 @@ async def to_aiohttp_response(bolt_resp: BoltResponse) -> web.Response: value=c.value, max_age=c.get("max-age"), expires=c.get("expires"), - path=c.get("path"), + 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..5e68c51f4 --- /dev/null +++ b/slack_bolt/adapter/asgi/base_handler.py @@ -0,0 +1,71 @@ +from typing import Callable, Dict, 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) -> Dict[str, str]: + while True: + lifespan = await receive() + if lifespan["type"] == "lifespan.startup": + """Do something before startup""" + return {"type": "lifespan.startup.complete"} + if lifespan["type"] == "lifespan.shutdown": + """Do something before shutdown""" + return {"type": "lifespan.shutdown.complete"} + + async def __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 send(await self._handle_lifespan(receive)) + 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..c8178b8f5 --- /dev/null +++ b/slack_bolt/adapter/asgi/http_response.py @@ -0,0 +1,29 @@ +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.raw_headers: List[Tuple[bytes, bytes]] = [ + (bytes(key, ENCODING), bytes(value[0], ENCODING)) for key, value in headers.items() + ] + self.raw_headers.append((b"content-length", bytes(str(len(body)), ENCODING))) + self.body: bytes = bytes(body, ENCODING) + + def get_response_start(self) -> Dict[str, Union[str, int, Iterable[Tuple[bytes, bytes]]]]: + return { + "type": "http.response.start", + "status": self.status, + "headers": self.raw_headers, + } + + def get_response_body(self) -> Dict[str, Union[str, bytes, bool]]: + return { + "type": "http.response.body", + "body": self.body, + "more_body": False, + } 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 d835055fb..00200b6da 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_handler.py +++ b/slack_bolt/adapter/aws_lambda/chalice_handler.py @@ -1,4 +1,8 @@ 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 @@ -14,11 +18,26 @@ 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.app.lazy_listener_runner = ChaliceLazyListenerRunner(logger=self.logger) + 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 = "?" @@ -31,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 @@ -43,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: @@ -56,7 +72,7 @@ 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 @@ -65,7 +81,7 @@ def handle(self, request: Request): aws_response = to_chalice_response(bolt_resp) return aws_response elif method == "NONE": - bolt_req: BoltRequest = to_bolt_request(request, body) + bolt_req = to_bolt_request(request, body) bolt_resp = self.app.dispatch(bolt_req) aws_response = to_chalice_response(bolt_resp) return aws_response @@ -74,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 index ce339b043..01c684e47 100644 --- a/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py +++ b/slack_bolt/adapter/aws_lambda/chalice_lazy_listener_runner.py @@ -1,15 +1,16 @@ import json from logging import Logger -from typing import Callable, Optional, Any +from typing import Callable, Optional -import boto3 +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[Any] = None): + def __init__(self, logger: Logger, lambda_client: Optional[BaseClient] = None): self.lambda_client = lambda_client self.logger = logger @@ -19,9 +20,7 @@ def start(self, function: Callable[..., None], request: BoltRequest) -> None: chalice_request: dict = request.context["chalice_request"] request.headers["x-slack-bolt-lazy-only"] = ["1"] - request.headers["x-slack-bolt-lazy-function-name"] = [ - request.lazy_function_name - ] + 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()}, @@ -38,4 +37,3 @@ def start(self, function: Callable[..., None], request: BoltRequest) -> None: InvocationType="Event", Payload=json.dumps(payload), ) - self.logger.info(invocation) diff --git a/slack_bolt/adapter/aws_lambda/handler.py b/slack_bolt/adapter/aws_lambda/handler.py index 0134c6011..4d155e58f 100644 --- a/slack_bolt/adapter/aws_lambda/handler.py +++ b/slack_bolt/adapter/aws_lambda/handler.py @@ -1,6 +1,6 @@ import base64 import logging -from typing import List, Dict, Any +from typing import Dict, Any, Sequence from slack_bolt.adapter.aws_lambda.internals import _first_value from slack_bolt.adapter.aws_lambda.lazy_listener_runner import LambdaLazyListenerRunner @@ -12,10 +12,10 @@ 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.app.lazy_listener_runner = LambdaLazyListenerRunner(self.logger) + 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 = "?" @@ -31,6 +31,9 @@ def handle(self, event, context): self.logger.debug(f"Incoming event: {event}, context: {context}") method = event.get("requestContext", {}).get("http", {}).get("method") + if method is None: + method = event.get("requestContext", {}).get("httpMethod") + if method is None: return not_found() if method == "GET": @@ -39,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: @@ -56,6 +56,7 @@ def handle(self, event, context): # 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) @@ -73,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 66ac7e155..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,8 +3,9 @@ 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 @@ -29,16 +30,10 @@ def __init__( 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"] - ) + 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 - ): + if settings.state_store is None or not isinstance(settings.state_store, AmazonS3OAuthStateStore): settings.state_store = AmazonS3OAuthStateStore( logger=logger, s3_client=self.s3_client, @@ -46,21 +41,33 @@ def __init__( expiration_seconds=settings.state_expiration_seconds, ) - if settings.installation_store is None or not isinstance( - settings.installation_store, AmazonS3InstallationStore - ): + 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() + 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 index 6e0e7e9a9..48703a96d 100644 --- a/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py +++ b/slack_bolt/adapter/aws_lambda/lazy_listener_runner.py @@ -2,7 +2,7 @@ from logging import Logger from typing import Callable, Optional, Any -import boto3 +import boto3 # type: ignore[import-untyped] from slack_bolt import BoltRequest from slack_bolt.lazy_listener import LazyListenerRunner @@ -20,12 +20,10 @@ def start(self, function: Callable[..., None], request: BoltRequest) -> None: event: dict = request.context["lambda_request"] headers = event["headers"] headers["x-slack-bolt-lazy-only"] = "1" # not an array - headers[ - "x-slack-bolt-lazy-function-name" - ] = request.lazy_function_name # not an array + headers["x-slack-bolt-lazy-function-name"] = request.lazy_function_name # not an array event["method"] = "NONE" invocation = self.lambda_client.invoke( - FunctionName=request.context["aws_lambda_function_name"], + FunctionName=request.context["aws_lambda_invoked_function_arn"], InvocationType="Event", Payload=json.dumps(event), ) 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 4b4d9aef0..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 @@ -10,7 +10,11 @@ def to_bolt_request(req: Request) -> BoltRequest: body = req.body.read() if isinstance(body, bytes): body = body.decode("utf-8") - return BoltRequest(body=body, query=req.query_string, headers=req.headers,) + return BoltRequest( + body=body, + query=req.query_string, + headers=req.headers, + ) def set_response(bolt_resp: BoltResponse, resp: Response) -> None: @@ -21,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: @@ -37,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 index f08c97a5f..83f4882db 100644 --- a/slack_bolt/adapter/cherrypy/__init__.py +++ b/slack_bolt/adapter/cherrypy/__init__.py @@ -1 +1,5 @@ from .handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/cherrypy/handler.py b/slack_bolt/adapter/cherrypy/handler.py index bd8dd0dc3..f3d714cc6 100644 --- a/slack_bolt/adapter/cherrypy/handler.py +++ b/slack_bolt/adapter/cherrypy/handler.py @@ -1,6 +1,6 @@ from typing import Optional -import cherrypy +import cherrypy # type: ignore[import-untyped] from slack_bolt.app import App from slack_bolt.oauth import OAuthFlow @@ -11,7 +11,11 @@ def build_bolt_request() -> BoltRequest: req = cherrypy.request body = req.raw_body if hasattr(req, "raw_body") else "" - return BoltRequest(body=body, query=req.query_string, headers=req.headers,) + return BoltRequest( + body=body, + query=req.query_string, + headers=req.headers, + ) def set_response_status_and_headers(bolt_resp: BoltResponse) -> None: @@ -51,7 +55,7 @@ def slack_processor(entity): class SlackRequestHandler: - def __init__(self, app: App): # type: ignore + def __init__(self, app: App): self.app = app def handle(self) -> bytes: @@ -69,7 +73,7 @@ def handle(self) -> bytes: set_response_status_and_headers(bolt_resp) return (bolt_resp.body or "").encode("utf-8") elif req.method == "POST": - bolt_resp: BoltResponse = self.app.dispatch(build_bolt_request()) + bolt_resp = self.app.dispatch(build_bolt_request()) set_response_status_and_headers(bolt_resp) return (bolt_resp.body or "").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 1749b67de..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,12 +26,17 @@ def to_bolt_request(req: HttpRequest) -> BoltRequest: raw_body: bytes = req.body body: str = raw_body.decode("utf-8") if raw_body else "" - return BoltRequest(body=body, query=req.META["QUERY_STRING"], headers=req.headers,) + return BoltRequest( + body=body, + query=req.META["QUERY_STRING"], + headers=req.headers, + ) def to_django_response(bolt_resp: BoltResponse) -> HttpResponse: resp: HttpResponse = HttpResponse( - status=bolt_resp.status, content=bolt_resp.body.encode("utf-8"), + status=bolt_resp.status, + content=bolt_resp.body.encode("utf-8"), ) for k, v in bolt_resp.first_headers_without_set_cookie().items(): resp[k] = v @@ -38,9 +58,109 @@ def to_django_response(bolt_resp: BoltResponse) -> HttpResponse: 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..8d03b456c --- /dev/null +++ b/slack_bolt/adapter/falcon/async_resource.py @@ -0,0 +1,77 @@ +from datetime import datetime +from http import HTTPStatus + +from falcon import version as falcon_version +from falcon.asgi import Request, Response +from slack_bolt import BoltResponse +from slack_bolt.async_app import AsyncApp +from slack_bolt.error import BoltError +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow +from slack_bolt.request.async_request import AsyncBoltRequest + + +class AsyncSlackAppResource: + """ + For use with ASGI Falcon Apps. + + from slack_bolt.async_app import AsyncApp + app = AsyncApp() + + import falcon + app = falcon.asgi.App() + app.add_route("/slack/events", AsyncSlackAppResource(app)) + """ + + def __init__(self, app: AsyncApp): + if falcon_version.__version__.startswith("2."): + raise BoltError("This ASGI compatible adapter requires Falcon version >= 3.0") + + self.app = app + + async def on_get(self, req: Request, resp: Response): + if self.app.oauth_flow is not None: + oauth_flow: AsyncOAuthFlow = self.app.oauth_flow + if req.path == oauth_flow.install_path: + bolt_resp = await oauth_flow.handle_installation(await self._to_bolt_request(req)) + await self._write_response(bolt_resp, resp) + return + elif req.path == oauth_flow.redirect_uri_path: + bolt_resp = await oauth_flow.handle_callback(await self._to_bolt_request(req)) + await self._write_response(bolt_resp, resp) + return + + resp.status = "404" + # Falcon 4.x w/ mypy fails to correctly infer the str type here + resp.body = "The page is not found..." + + async def on_post(self, req: Request, resp: Response): + bolt_req = await self._to_bolt_request(req) + bolt_resp = await self.app.async_dispatch(bolt_req) + await self._write_response(bolt_resp, resp) + + async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest: + return AsyncBoltRequest( + body=(await req.stream.read(req.content_length or 0)).decode("utf-8"), + query=req.query_string, + headers={k.lower(): v for k, v in req.headers.items()}, + ) + + async def _write_response(self, bolt_resp: BoltResponse, resp: Response): + resp.text = bolt_resp.body + status = HTTPStatus(bolt_resp.status) + resp.status = str(f"{status.value} {status.phrase}") + resp.set_headers(bolt_resp.first_headers_without_set_cookie()) + for cookie in bolt_resp.cookies(): + for name, c in cookie.items(): + expire_value = c.get("expires") + expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None + resp.set_cookie( + name=name, + value=c.value, + expires=expire, + max_age=c.get("max-age"), + domain=c.get("domain"), + path=c.get("path"), + secure=True, + http_only=True, + ) diff --git a/slack_bolt/adapter/falcon/resource.py b/slack_bolt/adapter/falcon/resource.py index dec1663a3..53792775f 100644 --- a/slack_bolt/adapter/falcon/resource.py +++ b/slack_bolt/adapter/falcon/resource.py @@ -1,7 +1,7 @@ from datetime import datetime from http import HTTPStatus -from falcon import Request, Response +from falcon import Request, Response, version as falcon_version from slack_bolt import BoltResponse from slack_bolt.app import App @@ -19,7 +19,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): @@ -35,6 +35,7 @@ def on_get(self, req: Request, resp: Response): return resp.status = "404" + # Falcon 4.x w/ mypy fails to correctly infer the str type here resp.body = "The page is not found..." def on_post(self, req: Request, resp: Response): @@ -50,18 +51,19 @@ def _to_bolt_request(self, req: Request) -> BoltRequest: ) def _write_response(self, bolt_resp: BoltResponse, resp: Response): - resp.body = bolt_resp.body + if falcon_version.__version__.startswith("2."): + # 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 - ) + 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, diff --git a/slack_bolt/adapter/fastapi/__init__.py b/slack_bolt/adapter/fastapi/__init__.py index 9ef794b02..224fd5fa2 100644 --- a/slack_bolt/adapter/fastapi/__init__.py +++ b/slack_bolt/adapter/fastapi/__init__.py @@ -1,2 +1,6 @@ # Don't add async module imports here -from ..starlette.handler import SlackRequestHandler # noqa +from ..starlette.handler import SlackRequestHandler + +__all__ = [ + "SlackRequestHandler", +] diff --git a/slack_bolt/adapter/fastapi/async_handler.py b/slack_bolt/adapter/fastapi/async_handler.py index 718f81874..f2c149c6d 100644 --- a/slack_bolt/adapter/fastapi/async_handler.py +++ b/slack_bolt/adapter/fastapi/async_handler.py @@ -1 +1,5 @@ -from ..starlette.async_handler import AsyncSlackRequestHandler # noqa +from ..starlette.async_handler import AsyncSlackRequestHandler + +__all__ = [ + "AsyncSlackRequestHandler", +] diff --git a/slack_bolt/adapter/flask/__init__.py b/slack_bolt/adapter/flask/__init__.py index 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 47fe96645..0dea272ac 100644 --- a/slack_bolt/adapter/pyramid/handler.py +++ b/slack_bolt/adapter/pyramid/handler.py @@ -1,7 +1,7 @@ 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 @@ -15,7 +15,9 @@ def to_bolt_request(request: Request) -> BoltRequest: else: body = request.body bolt_req = BoltRequest( - body=body, query=request.query_string, headers=request.headers, + body=body, + query=request.query_string, + headers=request.headers, ) return bolt_req @@ -35,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: @@ -43,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 a18af2250..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 + for key, c in cookie.items(): expire_value = c.get("expires") - if expire_value is not None and expire_value != "": - expire = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") - resp.cookies[name]["expires"] = expire - resp.cookies[name]["path"] = c.get("path") - resp.cookies[name]["domain"] = c.get("domain") - resp.cookies[name]["max-age"] = c.get("max-age") - resp.cookies[name]["secure"] = True - resp.cookies[name]["httponly"] = True + expires = datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z") if expire_value else None + max_age = int(c["max-age"]) if c.get("max-age") else None + path = str(c.get("path")) if c.get("path") else "/" + domain = str(c.get("domain")) if c.get("domain") else None + resp.add_cookie( + key=key, + value=c.value, + expires=expires, + path=path, + domain=domain, + max_age=max_age, + secure=True, + httponly=True, + ) + return resp 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..c2965f766 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -0,0 +1,46 @@ +"""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.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +async def run_async_bolt_app(app: AsyncApp, req: SocketModeRequest): + bolt_req: AsyncBoltRequest = AsyncBoltRequest(mode="socket_mode", body=req.payload) + bolt_resp: BoltResponse = await app.async_dispatch(bolt_req) + return bolt_resp + + +async def send_async_response( + client: AsyncBaseSocketModeClient, + req: SocketModeRequest, + bolt_resp: BoltResponse, + start_time: 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..8eb751b4d --- /dev/null +++ b/slack_bolt/adapter/socket_mode/internals.py @@ -0,0 +1,44 @@ +"""Internal functions""" + +import json +import logging +from time import time + +from slack_sdk.socket_mode.client import BaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def run_bolt_app(app: App, req: SocketModeRequest): + bolt_req: BoltRequest = BoltRequest(mode="socket_mode", body=req.payload) + bolt_resp: BoltResponse = app.dispatch(bolt_req) + return bolt_resp + + +def send_response( + client: BaseSocketModeClient, + req: SocketModeRequest, + bolt_resp: BoltResponse, + start_time: 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 a4fc0464b..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: @@ -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 e5e4f6057..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: @@ -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 47e06ba9d..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] ) @@ -55,11 +53,7 @@ def set_response(self, bolt_resp) -> None: 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 - ) + 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, 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..2861b4425 --- /dev/null +++ b/slack_bolt/adapter/wsgi/handler.py @@ -0,0 +1,82 @@ +from typing import Any, Callable, Dict, Iterable, List, Tuple + +from slack_bolt import App +from slack_bolt.adapter.wsgi.http_request import WsgiHttpRequest +from slack_bolt.adapter.wsgi.http_response import WsgiHttpResponse +from slack_bolt.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: Dict[str, Any], + start_response: Callable[[str, List[Tuple[str, str]]], None], + ) -> Iterable[bytes]: + request = WsgiHttpRequest(environ) + if "HTTP" in request.protocol: + response: WsgiHttpResponse = self._get_http_response( + request=request, + ) + start_response(response.status, response.get_headers()) + return response.get_body() + raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}") diff --git a/slack_bolt/adapter/wsgi/http_request.py b/slack_bolt/adapter/wsgi/http_request.py new file mode 100644 index 000000000..460d8f531 --- /dev/null +++ b/slack_bolt/adapter/wsgi/http_request.py @@ -0,0 +1,37 @@ +from typing import Any, Dict, Sequence, Union + +from .internals import ENCODING + + +class WsgiHttpRequest: + """This Class uses the PEP 3333 standard to extract request information + from the WSGI web server running the application + + PEP 3333: https://peps.python.org/pep-3333/ + """ + + __slots__ = ("method", "path", "query_string", "protocol", "environ") + + def __init__(self, environ: Dict[str, Any]): + self.method: str = environ.get("REQUEST_METHOD", "GET") + self.path: str = environ.get("PATH_INFO", "") + self.query_string: str = environ.get("QUERY_STRING", "") + self.protocol: str = environ.get("SERVER_PROTOCOL", "") + self.environ = environ + + def get_headers(self) -> Dict[str, 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", 0)) + return self.environ["wsgi.input"].read(content_length).decode(ENCODING) diff --git a/slack_bolt/adapter/wsgi/http_response.py b/slack_bolt/adapter/wsgi/http_response.py new file mode 100644 index 000000000..1ad32e672 --- /dev/null +++ b/slack_bolt/adapter/wsgi/http_response.py @@ -0,0 +1,33 @@ +from http import HTTPStatus +from typing import Dict, Iterable, List, Sequence, Tuple + +from .internals import ENCODING + + +class WsgiHttpResponse: + """This Class uses the PEP 3333 standard to adapt bolt response information + for the WSGI web server running the application + + PEP 3333: https://peps.python.org/pep-3333/ + """ + + __slots__ = ("status", "_headers", "_body") + + def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): + _status = HTTPStatus(status) + self.status = f"{_status.value} {_status.phrase}" + self._headers = headers + self._body = bytes(body, ENCODING) + + def get_headers(self) -> List[Tuple[str, str]]: + headers: List[Tuple[str, str]] = [] + for key, value in self._headers.items(): + if key.lower() == "content-length": + continue + headers.append((key, value[0])) + + headers.append(("content-length", str(len(self._body)))) + return headers + + def get_body(self) -> Iterable[bytes]: + return [self._body] diff --git a/slack_bolt/adapter/wsgi/internals.py b/slack_bolt/adapter/wsgi/internals.py new file mode 100644 index 000000000..cda3e876a --- /dev/null +++ b/slack_bolt/adapter/wsgi/internals.py @@ -0,0 +1 @@ +ENCODING = "utf-8" # The content encoding for Slack requests/responses is always utf-8 diff --git a/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 e4946706c..0af27913c 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -2,12 +2,13 @@ import json 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_bolt.listener.thread_runner import ThreadListenerRunner -from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware from slack_sdk.errors import SlackApiError from slack_sdk.oauth.installation_store import InstallationStore from slack_sdk.web import WebClient @@ -18,20 +19,29 @@ InstallationStoreAuthorize, CallableAuthorize, ) -from slack_bolt.error import BoltError + +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_signing_secret_not_found, + error_oauth_flow_or_authorize_required, warning_client_prioritized_and_token_skipped, warning_token_skipped, error_auth_test_failure, @@ -42,6 +52,13 @@ 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, @@ -51,14 +68,29 @@ 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: @@ -70,50 +102,127 @@ def __init__( name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps 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 - - :param name: The application name that will be used in logging. - If absent, the source file name will be used instead. - :param process_before_response: True if this app runs on Function as a Service. (Default: False) - :param signing_secret: The Signing Secret value used for verifying requests from Slack. - :param token: The bot access token required only for single-workspace app. - :param client: The singleton slack_sdk.WebClient instance for this app. - :param authorize: The function to authorize an incoming request from Slack - by checking if there is a team/user in the installation data. - :param installation_store: The module offering save/find operations of installation data - :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) - :param oauth_flow: Manually instantiated slack_bolt.oauth.OAuthFlow. - This is always prioritized over oauth_settings. - :param verification_token: Deprecated verification mechanism. - This can used only for ssl_check requests. + """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/building-an-app for details. + + If you would like to build an OAuth app for enabling the app to run with multiple workspaces, + refer to https://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) """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + if signing_secret is None: + signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") - if signing_secret is None or signing_secret == "": - raise BoltError(error_signing_secret_not_found()) - self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret - self._verification_token: Optional[str] = verification_token or os.environ.get( - "SLACK_VERIFICATION_TOKEN", None - ) + 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 @@ -123,23 +232,53 @@ def __init__( self._client = client self._token = client.token if token is not None: - self._framework_logger.warning( - warning_client_prioritized_and_token_skipped() - ) + 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, + ) + + # -------------------------------------- + # 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: - self._authorize = CallableAuthorize( - logger=self._framework_logger, func=authorize - ) + if isinstance(authorize, Authorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._authorize = CallableAuthorize(logger=self._framework_logger, func=authorize) self._installation_store: Optional[InstallationStore] = installation_store if self._installation_store is not None and self._authorize is None: + 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 @@ -152,79 +291,176 @@ def __init__( # initialize with the default settings oauth_settings = OAuthSettings() - if oauth_flow: + if oauth_flow is None and installation_store is None: + # show info-level log for avoiding confusions + self._framework_logger.info(info_default_oauth_settings_loaded()) + + if oauth_flow is not None: self._oauth_flow = oauth_flow - if self._installation_store is None: - self._installation_store = self._oauth_flow.settings.installation_store + installation_store = select_consistent_installation_store( + client_id=self._oauth_flow.client_id, + app_store=self._installation_store, + oauth_flow_store=self._oauth_flow.settings.installation_store, + logger=self._framework_logger, + ) + self._installation_store = installation_store + 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: - if self._installation_store: - # Consistently use a single installation_store - oauth_settings.installation_store = self._installation_store - - self._oauth_flow = OAuthFlow( - client=self.client, logger=self.logger, settings=oauth_settings + 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: + 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()) - self._middleware_list: List[Union[Callable, Middleware]] = [] + # 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] = [] - listener_executor = ThreadPoolExecutor(max_workers=5) + 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_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, + 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 is not None: try: - auth_test_result = self._client.auth_test(token=self._token) + 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) + 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) + 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( - MultiTeamsAuthorization(authorize=self._authorize) + 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 # ------------------------- @@ -232,42 +468,67 @@ 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 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: - """Start a web server for local development. - This method internally starts a Web server process built with the http.server module.' + 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. - :param port: The port to listen on (Default: 3000) - :param path: The path to handle request from Slack (Default: /slack/events) - :return: None + 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, + port=port, + path=path, + app=self, + oauth_flow=self.oauth_flow, + http_server_logger_enabled=http_server_logger_enabled, ) self._development_server.start() @@ -277,118 +538,266 @@ def start(self, port: int = 3000, path: str = "/slack/events") -> None: def dispatch(self, req: BoltRequest) -> BoltResponse: """Applies all middleware and dispatches an incoming request from Slack to the right code path. - :param req: An incoming request from Slack. - :return: The response generated by this Bolt app. + 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(debug_applying_middleware(middleware.name)) - resp = middleware.process(req=req, resp=resp, next=middleware_next) - if not middleware_state["next_called"]: - if resp is None: - return BoltResponse( - status=404, body={"error": "no next() calls in middleware"} + 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] ) - return resp + if next_was_not_called: + if middleware_resp is not None: + if self._framework_logger.level <= logging.DEBUG: + debug_message = debug_return_listener_middleware_response( + listener_name, + middleware_resp.status, + middleware_resp.body, + starting_time, + ) + self._framework_logger.debug(debug_message) + return middleware_resp + # The last listener middleware didn't call next() method. + # This means the listener is not for this incoming request. + continue + + if middleware_resp is not None: + resp = middleware_resp + + self._framework_logger.debug(debug_running_listener(listener_name)) + listener_response: Optional[BoltResponse] = self._listener_runner.run( + request=req, + response=resp, # type: ignore[arg-type] + listener_name=listener_name, + listener=listener, + ) + if listener_response is not None: + return listener_response - for listener in self._listeners: - listener_name = listener.ack_function.__name__ - self._framework_logger.debug(debug_checking_listener(listener_name)) - if listener.matches(req=req, resp=resp): - # run all the middleware attached to this listener first - resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) - 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(debug_running_listener(listener_name)) - listener_response: Optional[BoltResponse] = self._listener_runner.run( - request=req, - response=resp, - listener_name=listener_name, - listener=listener, - ) - if listener_response is not None: - return listener_response + if resp is None: + resp = BoltResponse(status=404, body={"error": "unhandled request"}) + if self._raise_error_for_unhandled_request is True: + 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 BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware def use(self, *args) -> Optional[Callable]: - """Refer to middleware method's docstring for details.""" + """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 Bolt app. + """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) - :param args: a list of middleware. Passing a single middleware is supported. - :return: None + 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): - self._middleware_list.append(middleware_or_callable) - elif isinstance(middleware_or_callable, Callable): + 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) + 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)})" - ) + raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})") return None # ------------------------- - # Workflows: Steps from Apps + # 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], - edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, - save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, - execute: Optional[ - Union[Callable[..., Optional[BoltResponse]], Listener] - ] = None, + callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder], + edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, + save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, + execute: Optional[Union[Callable[..., Optional[BoltResponse]], Listener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener""" + """ + 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, save=save, execute=execute, + 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("Invalid step object") + raise BoltError(f"Invalid step object ({type(step)})") - self.use(WorkflowStepMiddleware(step, self.listener_runner)) + self.use(WorkflowStepMiddleware(step)) # ------------------------- # global error handler - def error( - self, func: Callable[..., None] - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Updates the 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}") - :param func: The function that is supposed to be executed - when getting an unhandled error in Bolt app. - :return: None + # 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, + logger=self._framework_logger, + func=func, + ) + self._middleware_error_handler = CustomMiddlewareErrorHandler( + logger=self._framework_logger, + func=func, ) return func @@ -397,48 +806,156 @@ def error( def event( self, - event: Union[str, Pattern, Dict[str, str]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new event listener. - - :param event: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware, True - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new message event listener. - Check the #event method's docstring for details. + 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 = matchers if matchers else [] - middleware = middleware if middleware else [] + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] def __call__(*args, **kwargs): functions = self._to_listener_functions(kwargs) if kwargs else list(args) - primary_matcher = builtin_matchers.event( - {"type": "message", "subtype": None} - ) - middleware.append(MessageListenerMatches(keyword)) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware, True + 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__ @@ -448,23 +965,38 @@ def __call__(*args, **kwargs): def command( self, command: Union[str, Pattern], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + 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) - :param command: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + primary_matcher = builtin_matchers.command(command, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -474,57 +1006,74 @@ def __call__(*args, **kwargs): def shortcut( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + 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={ ... } + ) - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + # 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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__ @@ -534,91 +1083,102 @@ def __call__(*args, **kwargs): def action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new action listener. - - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new block_actions listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new interactive_message listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new dialog_submission listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new dialog_cancellation listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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__ @@ -628,57 +1188,82 @@ def __call__(*args, **kwargs): def view( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new view submission/closed event listener. + 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. - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new view_submission listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new view_closed listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + primary_matcher = builtin_matchers.view_closed(constraints, base_logger=self._base_logger) + return self._register_listener(list(functions), primary_matcher, matchers, middleware) return __call__ @@ -688,71 +1273,138 @@ def __call__(*args, **kwargs): def options( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., bool]]] = None, - middleware: Optional[List[Union[Callable, Middleware]]] = None, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new options listener. - - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new block_suggestion listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Optional[BoltResponse]]]: - """Registers a new dialog_submission listener.""" + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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[List[Callable[..., Optional[BoltResponse]]]]: + ) -> Optional[Sequence[Callable[..., Optional[BoltResponse]]]]: if kwargs: functions = [kwargs["ack"]] for sub in kwargs["lazy"]: @@ -762,11 +1414,12 @@ def _to_listener_functions( def _register_listener( self, - functions: List[Callable[..., Optional[BoltResponse]]], + functions: Sequence[Callable[..., Optional[BoltResponse]]], primary_matcher: ListenerMatcher, - matchers: Optional[List[Callable[..., bool]]], - middleware: Optional[List[Union[Callable, Middleware]]], + matchers: Optional[Sequence[Callable[..., bool]]], + middleware: Optional[Sequence[Union[Callable, Middleware]]], auto_acknowledgement: bool = False, + ack_timeout: int = 3, ) -> Optional[Callable[..., Optional[BoltResponse]]]: value_to_return = None if not isinstance(functions, list): @@ -776,16 +1429,16 @@ def _register_listener( # the registration should return the original function. value_to_return = functions[0] - listener_matchers = [ - CustomListenerMatcher(app_name=self.name, func=f) for f in (matchers or []) + 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(error_unexpected_listener_middleware(type(m))) @@ -793,10 +1446,12 @@ def _register_listener( CustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, + base_logger=self._base_logger, ) ) return value_to_return @@ -807,7 +1462,12 @@ def _register_listener( class SlackAppDevelopmentServer: def __init__( - self, port: int, path: str, app: App, oauth_flow: Optional[OAuthFlow] = None, + self, + port: int, + path: str, + app: App, + oauth_flow: Optional[OAuthFlow] = None, + http_server_logger_enabled: bool = True, ): """Slack App Development Server @@ -818,30 +1478,49 @@ def __init__( 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, 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) @@ -859,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) @@ -874,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) @@ -885,21 +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) -> None: - """Starts a new web server process. - - :return: None - """ + """Starts a new web server process.""" if self._bolt_app.logger.level > logging.INFO: - print("โšก๏ธ Bolt app is running! (development server)") + print(get_boot_message(development_server=True)) else: - self._bolt_app.logger.info("โšก๏ธ Bolt app is running! (development server)") + self._bolt_app.logger.info(get_boot_message(development_server=True)) try: self._server.serve_forever(0.05) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 272a01747..cc94f9e15 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -1,13 +1,39 @@ import inspect import logging import os -from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable +import time +from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable, Sequence, Any +import warnings +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.workflows.step.async_step import AsyncWorkflowStep +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, @@ -20,9 +46,9 @@ AsyncCallableAuthorize, AsyncInstallationStoreAuthorize, ) -from slack_bolt.error import BoltError +from slack_bolt.error import BoltError, BoltUnhandledRequestError from slack_bolt.logger.messages import ( - error_signing_secret_not_found, + error_oauth_flow_or_authorize_required, warning_client_prioritized_and_token_skipped, warning_token_skipped, error_token_required, @@ -32,6 +58,15 @@ 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 @@ -50,6 +85,8 @@ AsyncRequestVerification, AsyncIgnoringSelfEvents, AsyncUrlVerification, + AsyncAttachingFunctionToken, + AsyncAttachingConversationKwargs, ) from slack_bolt.middleware.async_custom_middleware import ( AsyncMiddleware, @@ -77,49 +114,120 @@ def __init__( name: Optional[str] = None, # Set True when you run this app on a FaaS platform process_before_response: bool = False, + # Set True if you want to handle an unhandled request as an exception + raise_error_for_unhandled_request: bool = False, # Basic Information > Credentials > Signing Secret signing_secret: Optional[str] = None, # for single-workspace apps token: Optional[str] = None, client: Optional[AsyncWebClient] = None, # for multi-workspace apps - installation_store: Optional[AsyncInstallationStore] = None, + 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 - - :param name: The application name that will be used in logging. - If absent, the source file name will be used instead. - :param process_before_response: True if this app runs on Function as a Service. (Default: False) - :param signing_secret: The Signing Secret value used for verifying requests from Slack. - :param token: The bot access token required only for single-workspace app. - :param client: The singleton slack_sdk.web.async_client.AsyncWebClient instance for this app. - :param installation_store: The module offering save/find operations of installation data - :param authorize: The function to authorize an incoming request from Slack - by checking if there is a team/user in the installation data. - :param oauth_settings: The settings related to Slack app installation flow (OAuth flow) - :param oauth_flow: Manually instantiated slack_bolt.oauth.async_oauth_flow.AsyncOAuthFlow. - This is always prioritized over oauth_settings. - :param verification_token: Deprecated verification mechanism. - This can used only for ssl_check requests. + """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) """ - signing_secret = signing_secret or os.environ.get("SLACK_SIGNING_SECRET") + if signing_secret is None: + signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "") token = token or os.environ.get("SLACK_BOT_TOKEN") - if signing_secret is None or signing_secret == "": - raise BoltError(error_signing_secret_not_found()) - self._name: str = name or inspect.stack()[1].filename.split(os.path.sep)[-1] self._signing_secret: str = signing_secret - self._verification_token: Optional[str] = verification_token or os.environ.get( - "SLACK_VERIFICATION_TOKEN", None - ) + 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 @@ -129,26 +237,53 @@ def __init__( self._async_client = client self._token = client.token if token is not None: - self._framework_logger.warning( - warning_client_prioritized_and_token_skipped() - ) + 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_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: - self._async_authorize = AsyncCallableAuthorize( - logger=self._framework_logger, func=authorize - ) + if isinstance(authorize, AsyncAuthorize): + # As long as an advanced developer understands what they're doing, + # bolt-python should not prevent customizing authorize middleware + self._async_authorize = authorize + else: + if oauth_settings is not None or oauth_flow is not None: + # If the given authorize is a simple function, + # it does not work along with installation_store. + raise BoltError(error_authorize_conflicts()) + self._async_authorize = AsyncCallableAuthorize(logger=self._framework_logger, func=authorize) - self._async_installation_store: Optional[ - AsyncInstallationStore - ] = installation_store + 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 @@ -161,76 +296,170 @@ def __init__( # initialize with the default settings oauth_settings = AsyncOAuthSettings() + if oauth_flow is None and installation_store is None: + # show info-level log for avoiding confusions + self._framework_logger.info(info_default_oauth_settings_loaded()) + if oauth_flow: + if not isinstance(oauth_flow, AsyncOAuthFlow): + raise BoltError(error_oauth_flow_invalid_type_async()) + self._async_oauth_flow = oauth_flow - if self._async_installation_store is None: - self._async_installation_store = ( - self._async_oauth_flow.settings.installation_store - ) + installation_store = select_consistent_installation_store( + client_id=self._async_oauth_flow.client_id, + app_store=self._async_installation_store, + oauth_flow_store=self._async_oauth_flow.settings.installation_store, + logger=self._framework_logger, + ) + self._async_installation_store = installation_store + 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 self._async_installation_store: - # Consistently use a single installation_store - oauth_settings.installation_store = self._async_installation_store + if not isinstance(oauth_settings, AsyncOAuthSettings): + raise BoltError(error_oauth_settings_invalid_type_async()) - self._async_oauth_flow = AsyncOAuthFlow( - client=self._async_client, logger=self.logger, settings=oauth_settings + 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: + 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()) - self._async_middleware_list: List[Union[Callable, AsyncMiddleware]] = [] + # 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_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) + 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()) - else: + elif self._async_authorize is not None: self._async_middleware_list.append( - AsyncMultiTeamsAuthorization(authorize=self._async_authorize) + AsyncMultiTeamsAuthorization( + authorize=self._async_authorize, + base_logger=self._base_logger, + 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()) - self._async_middleware_list.append(AsyncIgnoringSelfEvents()) - self._async_middleware_list.append(AsyncUrlVerification()) + if ignoring_self_events_enabled is True: + self._async_middleware_list.append( + AsyncIgnoringSelfEvents( + 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 # ------------------------- @@ -238,42 +467,98 @@ 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 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: - """Start a web server using AIOHTTP. + from .async_server import AsyncSlackAppServer - :param port: The port to listen on (Default: 3000) - :param path: The path to handle request from Slack (Default: /slack/events) - :return: None + 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) """ - from .async_server import AsyncSlackAppServer + 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() - self.server = AsyncSlackAppServer(port=port, path=path, app=self,) - self.server.start() + @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 @@ -281,130 +566,266 @@ def start(self, port: int = 3000, path: str = "/slack/events") -> None: async def async_dispatch(self, req: AsyncBoltRequest) -> BoltResponse: """Applies all middleware and dispatches an incoming request from Slack to the right code path. - :param req: An incoming request from Slack. - :return: The response generated by this Bolt app. + 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"]: - if resp is None: - return BoltResponse( - status=404, body={"error": "no next() calls in middleware"} + 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 - for listener in self._async_listeners: - listener_name = listener.ack_function.__name__ - self._framework_logger.debug(debug_checking_listener(listener_name)) - if await listener.async_matches(req=req, resp=resp): - # run all the middleware attached to this listener first - resp, next_was_not_called = await listener.run_async_middleware( - 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(debug_running_listener(listener_name)) - listener_response: Optional[ - BoltResponse - ] = await self._async_listener_runner.run( - request=req, - response=resp, - listener_name=listener_name, - listener=listener, - ) - if listener_response is not None: - return listener_response - + def _handle_unmatched_requests(self, req: AsyncBoltRequest, resp: BoltResponse) -> BoltResponse: self._framework_logger.warning(warning_unhandled_request(req)) - return BoltResponse(status=404, body={"error": "unhandled request"}) + return resp # ------------------------- # middleware def use(self, *args) -> Optional[Callable]: - """Refer to middleware method's docstring for details.""" + """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 Bolt app. + """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. - :param args: a list of middleware. Passing a single middleware is supported. - :return: None + 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): - self._async_middleware_list.append(middleware_or_callable) - elif isinstance(middleware_or_callable, Callable): + 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 + 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)})" - ) + 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 + # Workflows: Steps from apps def step( self, - callback_id: Union[str, Pattern, AsyncWorkflowStep], - edit: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener] - ] = None, - save: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener] - ] = None, - execute: Optional[ - Union[Callable[..., Optional[BoltResponse]], AsyncListener] - ] = None, + callback_id: Union[str, Pattern, AsyncWorkflowStep, AsyncWorkflowStepBuilder], + edit: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, + save: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, + execute: Optional[Union[Callable[..., Optional[BoltResponse]], AsyncListener, Sequence[Callable]]] = None, ): - """Registers a new Workflow Step listener""" + """ + 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, save=save, execute=execute, + 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("Invalid step object") + raise BoltError(f"Invalid step object ({type(step)})") - self.use(AsyncWorkflowStepMiddleware(step, self._async_listener_runner)) + self.use(AsyncWorkflowStepMiddleware(step)) # ------------------------- # global error handler def error( - self, func: Callable[..., Awaitable[None]] - ) -> Callable[..., Awaitable[None]]: - """Updates the global error handler. + 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}") - :param func: The function that is supposed to be executed - when getting an unhandled error in Bolt app. - :return: None + # 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, + logger=self._framework_logger, + func=func, + ) + self._async_middleware_error_handler = AsyncCustomMiddlewareErrorHandler( + logger=self._framework_logger, + func=func, ) return func @@ -413,46 +834,160 @@ def error( def event( self, - event: Union[str, Pattern, Dict[str, str]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new event listener. - - :param event: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware, True - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Register a new message event listener.""" - matchers = matchers if matchers else [] - middleware = middleware if middleware else [] + 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) - primary_matcher = builtin_matchers.event( - {"type": "message", "subtype": None}, True + constraints = { + "type": "message", + "subtype": ( + # In most cases, new message events come with no subtype. + None, + # As of Jan 2021, most bot messages no longer have the subtype bot_message. + # By contrast, messages posted using classic app's bot token still have the subtype. + "bot_message", + # If an end-user posts a message with "Also send to #channel" checked, + # the message event comes with this subtype. + "thread_broadcast", + # If an end-user posts a message with attached files, + # the message event comes with this subtype. + "file_share", + ), + } + primary_matcher = builtin_matchers.message_event( + constraints=constraints, + keyword=keyword, + asyncio=True, + base_logger=self._base_logger, ) - middleware.append(AsyncMessageListenerMatches(keyword)) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware, True + 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__ @@ -462,23 +997,38 @@ def __call__(*args, **kwargs): def command( self, command: Union[str, Pattern], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + 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']}") - :param command: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + # 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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__ @@ -488,57 +1038,74 @@ def __call__(*args, **kwargs): def shortcut( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + 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. - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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__ @@ -548,91 +1115,102 @@ def __call__(*args, **kwargs): def action( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new action listener. - - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new block_actions listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new interactive_message listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new dialog_submission listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new dialog_cancellation listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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__ @@ -642,57 +1220,82 @@ def __call__(*args, **kwargs): def view( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new view submission/closed listener. - - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new view_submission listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new view_closed listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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__ @@ -702,71 +1305,142 @@ def __call__(*args, **kwargs): def options( self, constraints: Union[str, Pattern, Dict[str, Union[str, Pattern]]], - matchers: Optional[List[Callable[..., Awaitable[bool]]]] = None, - middleware: Optional[List[Union[Callable, AsyncMiddleware]]] = None, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: + 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. - - :param constraints: The conditions to match against a request payload - :param matchers: A list of listener matcher functions. - :param middleware: A list of lister middleware functions. - :return: None + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new block_suggestion listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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, - ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: - """Registers a new dialog_submission listener.""" + 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, True) - return self._register_listener( - list(functions), primary_matcher, matchers, middleware - ) + 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[List[Callable[..., Awaitable[Optional[BoltResponse]]]]]: + ) -> Optional[Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]]]: if kwargs: functions = [kwargs["ack"]] for sub in kwargs["lazy"]: @@ -776,11 +1450,12 @@ def _to_listener_functions( def _register_listener( self, - functions: List[Callable[..., Awaitable[Optional[BoltResponse]]]], + functions: Sequence[Callable[..., Awaitable[Optional[BoltResponse]]]], primary_matcher: AsyncListenerMatcher, - matchers: Optional[List[Callable[..., Awaitable[bool]]]], - middleware: Optional[List[Union[Callable, AsyncMiddleware]]], + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]], + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]], auto_acknowledgement: bool = False, + ack_timeout: int = 3, ) -> Optional[Callable[..., Awaitable[Optional[BoltResponse]]]]: value_to_return = None if not isinstance(functions, list): @@ -791,23 +1466,20 @@ def _register_listener( value_to_return = functions[0] for func in functions: - if not inspect.iscoroutinefunction(func): - name = func.__name__ + if not is_callable_coroutine(func): + name = get_name_for_callable(func) raise BoltError(error_listener_function_must_be_coro_func(name)) - listener_matchers = [ - AsyncCustomListenerMatcher(app_name=self.name, func=f) - for f in (matchers or []) + 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(error_unexpected_listener_middleware(type(m))) @@ -815,10 +1487,12 @@ def _register_listener( AsyncCustomListener( app_name=self.name, ack_function=functions.pop(0), - lazy_functions=functions, + lazy_functions=functions, # type: ignore[arg-type] matchers=listener_matchers, middleware=listener_middleware, auto_acknowledgement=auto_acknowledgement, + ack_timeout=ack_timeout, + base_logger=self._base_logger, ) ) diff --git a/slack_bolt/app/async_server.py b/slack_bolt/app/async_server.py index f4272e710..f21d35932 100644 --- a/slack_bolt/app/async_server.py +++ b/slack_bolt/app/async_server.py @@ -1,43 +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, ): - """Standalone AIOHTTP Web Server + """Standalone AIOHTTP Web Server. + Refer to https://docs.aiohttp.org/en/stable/web.html for details of AIOHTTP. - Refer to AIOHTTP documents for details. - https://docs.aiohttp.org/en/stable/web.html + 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._endpoint_path = path - self._bolt_app: "AsyncApp" = app - + 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 @@ -56,21 +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) -> None: - """ Starts a new web server process. - - :return: None - """ - 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/authorization/__init__.py b/slack_bolt/authorization/__init__.py index 055f3a2aa..4b80a93bb 100644 --- a/slack_bolt/authorization/__init__.py +++ b/slack_bolt/authorization/__init__.py @@ -1 +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 index 86eb563cd..f3303e429 100644 --- a/slack_bolt/authorization/async_authorize.py +++ b/slack_bolt/authorization/async_authorize.py @@ -1,19 +1,25 @@ -import inspect from logging import Logger -from typing import Optional, Callable, Awaitable, Dict, Any +from typing import Optional, Callable, Awaitable, Dict, Any, Sequence -from slack_sdk.errors import SlackApiError -from slack_sdk.oauth.installation_store import Bot +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 @@ -22,27 +28,37 @@ async def __call__( *, context: AsyncBoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # 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): - def __init__( - self, *, logger: Logger, func: Callable[..., Awaitable[AuthorizeResult]] - ): + """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 = inspect.getfullargspec(func).args + self.arg_names = get_arg_names_of_callable(func) async def __call__( self, *, context: AsyncBoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # 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 = { @@ -58,14 +74,15 @@ async def __call__( "enterprise_id": enterprise_id, "team_id": team_id, "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, } for k, v in context.items(): if k not in all_available_args: all_available_args[k] = v - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } + 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: @@ -79,9 +96,7 @@ async def __call__( if isinstance(auth_result, AuthorizeResult): return auth_result else: - raise ValueError( - f"Unexpected returned value from authorize function (type: {type(auth_result)})" - ) + 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} " @@ -91,40 +106,272 @@ async def __call__( class AsyncInstallationStoreAuthorize(AsyncAuthorize): + """If you use the OAuth flow settings, this authorize implementation will be used. + As long as your own InstallationStore (or the built-in ones) works as you expect, + you can expect that the authorize layer should work for you without any customization. + """ + + authorize_result_cache: Dict[str, AuthorizeResult] + 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, + 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: 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: Optional[Bot] = await self.installation_store.async_find_bot( - enterprise_id=enterprise_id, team_id=team_id, - ) - if bot is None: - self.logger.debug( - f"No installation data found " - f"for enterprise_id: {enterprise_id} team_id: {team_id}" - ) + + if self.find_installation_available is None: + self.find_installation_available = hasattr(self.installation_store, "async_find_installation") + 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_result = await context.client.auth_test(token=bot.bot_token) - return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_result, - bot_token=bot.bot_token, - user_token=None, # Not yet supported + 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 index 5100ad19f..08af16766 100644 --- a/slack_bolt/authorization/async_authorize_args.py +++ b/slack_bolt/authorization/async_authorize_args.py @@ -11,7 +11,7 @@ class AsyncAuthorizeArgs: logger: Logger client: AsyncWebClient enterprise_id: Optional[str] - team_id: str + team_id: Optional[str] user_id: Optional[str] def __init__( @@ -19,15 +19,16 @@ def __init__( *, context: AsyncBoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ): - """The whole arguments that are passed to Authorize functions. + """The full list of the arguments passed to `authorize` function. - :param context: The request context - :param enterprise_id: The Organization ID (Enterprise Grid) - :param team_id: The workspace ID - :param user_id: The request user ID + 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 diff --git a/slack_bolt/authorization/authorize.py b/slack_bolt/authorization/authorize.py index 47e923af4..afed6fa8b 100644 --- a/slack_bolt/authorization/authorize.py +++ b/slack_bolt/authorization/authorize.py @@ -1,17 +1,24 @@ -import inspect from logging import Logger -from typing import Optional, Callable, Dict, Any +from typing import Optional, Callable, Dict, Any, Sequence -from slack_sdk.errors import SlackApiError +from slack_sdk.errors import SlackApiError, SlackTokenRotationError from slack_sdk.oauth import InstallationStore -from slack_sdk.oauth.installation_store import Bot +from slack_sdk.oauth.installation_store.models.bot import Bot +from slack_sdk.oauth.installation_store.models.installation import Installation +from slack_sdk.oauth.token_rotation.rotator import TokenRotator +from slack_sdk.web import WebClient from slack_bolt.authorization.authorize_args import AuthorizeArgs from slack_bolt.authorization.authorize_result import AuthorizeResult 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 @@ -20,27 +27,42 @@ def __call__( *, context: BoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # 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, + func: Callable[..., AuthorizeResult], ): self.logger = logger self.func = func - self.arg_names = inspect.getfullargspec(func).args + self.arg_names = get_arg_names_of_callable(func) def __call__( self, *, context: BoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], + # 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 = { @@ -56,14 +78,15 @@ def __call__( "enterprise_id": enterprise_id, "team_id": team_id, "user_id": user_id, + "actor_enterprise_id": actor_enterprise_id, + "actor_team_id": actor_team_id, + "actor_user_id": actor_user_id, } for k, v in context.items(): if k not in all_available_args: all_available_args[k] = v - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } + 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: @@ -77,9 +100,7 @@ def __call__( if isinstance(auth_result, AuthorizeResult): return auth_result else: - raise ValueError( - f"Unexpected returned value from authorize function (type: {type(auth_result)})" - ) + 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} " @@ -89,40 +110,265 @@ def __call__( class InstallationStoreAuthorize(Authorize): + """If you use the OAuth flow settings, this `authorize` implementation will be used. + As long as your own InstallationStore (or the built-in ones) works as you expect, + you can expect that the `authorize` layer should work for you without any customization. + """ + + authorize_result_cache: Dict[str, AuthorizeResult] + bot_only: bool + 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, + 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: 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: Optional[Bot] = self.installation_store.find_bot( - enterprise_id=enterprise_id, team_id=team_id, - ) - if bot is None: - self.logger.debug( - f"No installation data found " - f"for enterprise_id: {enterprise_id} team_id: {team_id}" - ) + + bot_token: Optional[str] = None + user_token: Optional[str] = None + 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_result = context.client.auth_test(token=bot.bot_token) - return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_result, - bot_token=bot.bot_token, - user_token=None, # Not yet supported + 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 index 170cd7843..2d436b697 100644 --- a/slack_bolt/authorization/authorize_args.py +++ b/slack_bolt/authorization/authorize_args.py @@ -11,7 +11,7 @@ class AuthorizeArgs: logger: Logger client: WebClient enterprise_id: Optional[str] - team_id: str + team_id: Optional[str] user_id: Optional[str] def __init__( @@ -19,15 +19,16 @@ def __init__( *, context: BoltContext, enterprise_id: Optional[str], - team_id: str, + team_id: Optional[str], # can be None for org-wide installed apps user_id: Optional[str], ): - """The whole arguments that are passed to Authorize functions. + """The full list of the arguments passed to `authorize` function. - :param context: The request context - :param enterprise_id: The Organization ID (Enterprise Grid) - :param team_id: The workspace ID - :param user_id: The request user ID + 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 diff --git a/slack_bolt/authorization/authorize_result.py b/slack_bolt/authorization/authorize_result.py index 0a80a3d7a..cbf1a4678 100644 --- a/slack_bolt/authorization/authorize_result.py +++ b/slack_bolt/authorization/authorize_result.py @@ -1,49 +1,77 @@ -from typing import Optional +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, ): - """The `auth.test` API result for an incoming request. - - :param enterprise_id: Organization ID (Enterprise Grid) - :param team_id: Workspace ID - :param bot_user_id: Bot user's User ID - :param bot_id: Bot ID - :param bot_token: Bot user access token starting with xoxb- - :param user_id: The request user ID - :param user_token: User access token starting with xoxp- + """ + 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( @@ -51,24 +79,31 @@ def from_auth_test_response( *, bot_token: Optional[str] = None, user_token: Optional[str] = None, - auth_test_response: SlackResponse, + 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] = ( # type:ignore - auth_test_response.get("user_id") - if auth_test_response.get("bot_id") is not None - else None - ) - user_id: Optional[str] = ( # type:ignore - auth_test_response.get("user_id") - if auth_test_response.get("bot_id") is None - else None + 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 bc9b7c82d..5ebc5f5ad 100644 --- a/slack_bolt/context/ack/ack.py +++ b/slack_bolt/context/ack/ack.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union, Dict +from typing import Optional, Union, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup @@ -17,12 +17,14 @@ def __init__(self): def __call__( self, text: Union[str, dict] = "", # text: str or whole_response: dict - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, # view_submission response_action: Optional[str] = None, # errors / update / push / clear errors: Optional[Dict[str, str]] = None, @@ -33,6 +35,8 @@ def __call__( text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, diff --git a/slack_bolt/context/ack/async_ack.py b/slack_bolt/context/ack/async_ack.py index 380131e4e..c12e7b6d2 100644 --- a/slack_bolt/context/ack/async_ack.py +++ b/slack_bolt/context/ack/async_ack.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union, Dict +from typing import Optional, Union, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup @@ -17,12 +17,14 @@ def __init__(self): async def __call__( self, text: Union[str, dict] = "", # text: str or whole_response: dict - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, # view_submission response_action: Optional[str] = None, # errors / update / push / clear errors: Optional[Dict[str, str]] = None, @@ -33,6 +35,8 @@ async def __call__( text_or_whole_response=text, blocks=blocks, attachments=attachments, + unfurl_links=unfurl_links, + unfurl_media=unfurl_media, response_type=response_type, options=options, option_groups=option_groups, diff --git a/slack_bolt/context/ack/internals.py b/slack_bolt/context/ack/internals.py index c6ee73755..8ea5576c2 100644 --- a/slack_bolt/context/ack/internals.py +++ b/slack_bolt/context/ack/internals.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Union, Any, Dict +from typing import Optional, Union, Any, Dict, Sequence from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, Option, OptionGroup @@ -12,34 +12,38 @@ def _set_response( self: Any, text_or_whole_response: Union[str, dict] = "", - blocks: Optional[List[Union[dict, Block]]] = None, - attachments: Optional[List[Union[dict, Attachment]]] = None, + blocks: Optional[Sequence[Union[dict, Block]]] = None, + attachments: Optional[Sequence[Union[dict, Attachment]]] = None, + unfurl_links: Optional[bool] = None, + unfurl_media: Optional[bool] = None, response_type: Optional[str] = None, # in_channel / ephemeral # block_suggestion / dialog_suggestion - options: Optional[List[Union[dict, Option]]] = None, - option_groups: Optional[List[Union[dict, OptionGroup]]] = None, + options: Optional[Sequence[Union[dict, Option]]] = None, + option_groups: Optional[Sequence[Union[dict, OptionGroup]]] = None, # view_submission response_action: Optional[str] = None, - errors: Optional[Union[Dict[str, str], List[Dict[str, str]]]] = None, + errors: Optional[Union[Dict[str, str], Sequence[Dict[str, str]]]] = None, view: Optional[Union[dict, View]] = None, ) -> BoltResponse: if isinstance(text_or_whole_response, str): text: str = text_or_whole_response - body = {"text": text} + 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: - body.update( - {"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: body.update({"text": text, "blocks": convert_to_dict_list(blocks)}) self.response = BoltResponse(status=200, body=body) - elif options and len(options) > 0: + elif options is not None: body = {"options": convert_to_dict_list(options)} self.response = BoltResponse(status=200, body=body) - elif option_groups and len(option_groups) > 0: + elif option_groups is not None: body = {"option_groups": convert_to_dict_list(option_groups)} self.response = BoltResponse(status=200, body=body) elif response_action: @@ -50,13 +54,11 @@ def _set_response( status=200, body={ "response_action": response_action, - "errors": convert_to_dict(errors), + "errors": convert_to_dict(errors), # type: ignore[arg-type] }, ) else: - raise ValueError( - f"errors field is required for response_action: errors" - ) + raise ValueError("errors field is required for response_action: errors") else: body = {"response_action": response_action} if view: @@ -64,7 +66,7 @@ def _set_response( self.response = BoltResponse(status=200, body=body) elif errors: # dialogs: errors without response_action - body = {"errors": convert_to_dict_list(errors)} + body = {"errors": convert_to_dict_list(errors)} # type: ignore[arg-type] self.response = BoltResponse(status=200, body=body) else: if len(body) == 1 and "text" in body: @@ -96,6 +98,4 @@ def _set_response( 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..42f05c94b --- /dev/null +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -0,0 +1,93 @@ +import warnings +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_status import SetStatus +from ..set_suggested_prompts import SetSuggestedPrompts +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})") + + def is_valid(self) -> bool: + return self.channel_id is not None and self.thread_ts is not None + + @property + def set_status(self) -> SetStatus: + warnings.warn( + "AssistantUtilities.set_status is deprecated. " + "Use the set_status argument directly in your listener function " + "or access it via context.set_status instead.", + DeprecationWarning, + stacklevel=2, + ) + return SetStatus(self.client, self.channel_id, self.thread_ts) + + @property + def set_title(self) -> SetTitle: + return SetTitle(self.client, self.channel_id, self.thread_ts) + + @property + def set_suggested_prompts(self) -> SetSuggestedPrompts: + return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) + + @property + def say(self) -> Say: + def build_metadata() -> Optional[dict]: + 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..b40b2619c --- /dev/null +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -0,0 +1,96 @@ +import warnings +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_status.async_set_status import AsyncSetStatus +from ..set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +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})") + + def is_valid(self) -> bool: + return self.channel_id is not None and self.thread_ts is not None + + @property + def set_status(self) -> AsyncSetStatus: + warnings.warn( + "AsyncAssistantUtilities.set_status is deprecated. " + "Use the set_status argument directly in your listener function " + "or access it via context.set_status instead.", + DeprecationWarning, + stacklevel=2, + ) + return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) + + @property + def set_title(self) -> AsyncSetTitle: + return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) + + @property + def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts: + return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) + + @property + def say(self) -> AsyncSay: + return AsyncSay( + 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 8d646bf51..94b2b5cbe 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -1,34 +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) -> Optional[AsyncWebClient]: + 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 39468b922..502febcb8 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -1,36 +1,124 @@ from logging import Logger -from typing import Optional, Tuple +from typing import Any, Dict, Optional, Tuple from slack_bolt.authorization import AuthorizeResult 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 team_id(self) -> str: - return self["team_id"] + 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 @@ -38,26 +126,52 @@ 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): 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 964f476de..b101460a5 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -1,35 +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) -> Optional[WebClient]: + 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 8b9eae3df..c492e5d77 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -1,51 +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, - thread_ts=thread_ts, + 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 d8dfe145b..a6e5904e3 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -1,52 +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, - thread_ts=thread_ts, + 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..af776891b --- /dev/null +++ b/slack_bolt/context/say_stream/async_say_stream.py @@ -0,0 +1,62 @@ +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, + **kwargs, + ) -> AsyncChatStream: + """Starts a new chat stream with context.""" + channel = channel or self.channel + thread_ts = thread_ts or self.thread_ts + if channel is None: + raise ValueError("say_stream without channel here is unsupported") + if thread_ts is None: + raise ValueError("say_stream without thread_ts here is unsupported") + + if buffer_size is not None: + return await self.client.chat_stream( + buffer_size=buffer_size, + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) + return await self.client.chat_stream( + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) 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..b6a5ca797 --- /dev/null +++ b/slack_bolt/context/say_stream/say_stream.py @@ -0,0 +1,62 @@ +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, + **kwargs, + ) -> ChatStream: + """Starts a new chat stream with context.""" + channel = channel or self.channel + thread_ts = thread_ts or self.thread_ts + if channel is None: + raise ValueError("say_stream without channel here is unsupported") + if thread_ts is None: + raise ValueError("say_stream without thread_ts here is unsupported") + + if buffer_size is not None: + return self.client.chat_stream( + buffer_size=buffer_size, + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) + return self.client.chat_stream( + channel=channel, + recipient_team_id=recipient_team_id or self.recipient_team_id, + recipient_user_id=recipient_user_id or self.recipient_user_id, + thread_ts=thread_ts, + **kwargs, + ) 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..e2c451f46 --- /dev/null +++ b/slack_bolt/context/set_status/async_set_status.py @@ -0,0 +1,34 @@ +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, + **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, + **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..0ed612e16 --- /dev/null +++ b/slack_bolt/context/set_status/set_status.py @@ -0,0 +1,34 @@ +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, + **kwargs, + ) -> SlackResponse: + return self.client.assistant_threads_setStatus( + channel_id=self.channel_id, + thread_ts=self.thread_ts, + status=status, + loading_messages=loading_messages, + **kwargs, + ) diff --git a/slack_bolt/context/set_suggested_prompts/__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..2079b6448 --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py @@ -0,0 +1,39 @@ +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: 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, + prompts: Sequence[Union[str, Dict[str, str]]], + title: 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=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..21ff815e1 --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -0,0 +1,39 @@ +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: 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, + prompts: Sequence[Union[str, Dict[str, str]]], + title: 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=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 0774b183a..f2b4099d6 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -1,41 +1,115 @@ -# pytype: skip-file import logging from logging import Logger 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, @@ -57,8 +131,19 @@ def __init__( 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 @@ -79,4 +164,15 @@ def __init__( 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 47dba2259..2217cfe9f 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -1,40 +1,114 @@ -# pytype: skip-file from logging import Logger 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, @@ -56,8 +130,16 @@ def __init__( 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 @@ -78,4 +160,15 @@ def __init__( 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 58704e2bc..246fd10c9 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -1,11 +1,11 @@ -# 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.util.payload_utils import ( +from slack_bolt.request.payload_utils import ( to_options, to_shortcut, to_action, @@ -15,17 +15,21 @@ 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, @@ -47,9 +51,24 @@ def build_async_required_kwargs( "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"] @@ -65,20 +84,30 @@ def build_async_required_kwargs( if k not in all_available_args: all_available_args[k] = v - kwargs: Dict[str, Any] = { - k: v for k, v in all_available_args.items() if k in required_arg_names - } + 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 5519b2a5e..218fbeb6e 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,11 +1,11 @@ -# 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.util.payload_utils import ( +from slack_bolt.request.payload_utils import ( to_options, to_shortcut, to_action, @@ -15,17 +15,21 @@ 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, @@ -47,9 +51,23 @@ def build_required_kwargs( "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"] @@ -65,20 +83,30 @@ def build_required_kwargs( if k not in all_available_args: all_available_args[k] = v - kwargs: Dict[str, Any] = { - k: v for k, v in all_available_args.items() if k in required_arg_names - } + 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 index 1b2968a22..a92c18483 100644 --- a/slack_bolt/lazy_listener/__init__.py +++ b/slack_bolt/lazy_listener/__init__.py @@ -1,3 +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 # noqa -from .thread_runner import ThreadLazyListenerRunner # noqa +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 index a3e3bfe79..e1134170c 100644 --- a/slack_bolt/lazy_listener/async_internals.py +++ b/slack_bolt/lazy_listener/async_internals.py @@ -1,10 +1,10 @@ -import inspect from functools import wraps from logging import Logger from typing import Callable, Awaitable from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.util.utils import get_arg_names_of_callable async def to_runnable_function( @@ -12,7 +12,7 @@ async def to_runnable_function( logger: Logger, request: AsyncBoltRequest, ): - arg_names = inspect.getfullargspec(internal_func).args + arg_names = get_arg_names_of_callable(internal_func) @wraps(internal_func) async def request_wired_wrapper() -> None: @@ -23,6 +23,7 @@ async def request_wired_wrapper() -> None: required_arg_names=arg_names, request=request, response=None, + this_func=internal_func, ) ) except Exception as e: diff --git a/slack_bolt/lazy_listener/async_runner.py b/slack_bolt/lazy_listener/async_runner.py index d640d48ec..976d404ed 100644 --- a/slack_bolt/lazy_listener/async_runner.py +++ b/slack_bolt/lazy_listener/async_runner.py @@ -1,6 +1,6 @@ from abc import abstractmethod, ABCMeta from logging import Logger -from typing import Callable, Awaitable, Any, Coroutine +from typing import Callable, Awaitable from slack_bolt.lazy_listener.async_internals import to_runnable_function from slack_bolt.request.async_request import AsyncBoltRequest @@ -10,27 +10,25 @@ class AsyncLazyListenerRunner(metaclass=ABCMeta): logger: Logger @abstractmethod - def start( - self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest - ) -> None: + def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: """Starts a new lazy listener execution. - :param function: The function to run. - :param request: The request to pass to the function. The object must be thread-safe. - :return: None + 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: + async def run(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: """Synchronously run the function with a given request data. - :param function: The function to run. - :param request: The request to pass to the function. The object must be thread-safe. - :return: None + 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, + internal_func=function, + logger=self.logger, + request=request, ) - return await func() # type: ignore + return await func() # type: ignore[operator] diff --git a/slack_bolt/lazy_listener/asyncio_runner.py b/slack_bolt/lazy_listener/asyncio_runner.py index f9ceb6cf3..7c88e5d1d 100644 --- a/slack_bolt/lazy_listener/asyncio_runner.py +++ b/slack_bolt/lazy_listener/asyncio_runner.py @@ -11,15 +11,16 @@ class AsyncioLazyListenerRunner(AsyncLazyListenerRunner): logger: Logger def __init__( - self, logger: Logger, + self, + logger: Logger, ): self.logger = logger - def start( - self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest - ) -> None: + def start(self, function: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: asyncio.ensure_future( to_runnable_function( - internal_func=function, logger=self.logger, request=request, + internal_func=function, + logger=self.logger, + request=request, ) ) diff --git a/slack_bolt/lazy_listener/internals.py b/slack_bolt/lazy_listener/internals.py index ee6659540..ce088c67e 100644 --- a/slack_bolt/lazy_listener/internals.py +++ b/slack_bolt/lazy_listener/internals.py @@ -1,16 +1,18 @@ -import inspect from functools import wraps from logging import Logger from typing import Callable from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.request import BoltRequest +from slack_bolt.util.utils import get_arg_names_of_callable def build_runnable_function( - func: Callable[..., None], logger: Logger, request: BoltRequest, + func: Callable[..., None], + logger: Logger, + request: BoltRequest, ) -> Callable[[], None]: - arg_names = inspect.getfullargspec(func).args + arg_names = get_arg_names_of_callable(func) @wraps(func) def request_wired_func_wrapper() -> None: @@ -21,6 +23,7 @@ def request_wired_func_wrapper() -> None: required_arg_names=arg_names, request=request, response=None, + this_func=func, ) ) except Exception as e: diff --git a/slack_bolt/lazy_listener/runner.py b/slack_bolt/lazy_listener/runner.py index 3442693ea..a9b55723c 100644 --- a/slack_bolt/lazy_listener/runner.py +++ b/slack_bolt/lazy_listener/runner.py @@ -13,17 +13,21 @@ class LazyListenerRunner(metaclass=ABCMeta): def start(self, function: Callable[..., None], request: BoltRequest) -> None: """Starts a new lazy listener execution. - :param function: The function to run. - :param request: The request to pass to the function. The object must be thread-safe. - :return: None + 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 run the function with a given request data. + """Synchronously runs the function with a given request data. - :param function: The function to run. - :param request: The request to pass to the function. The object must be thread-safe. - :return: None + 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,)() + build_runnable_function( + func=function, + logger=self.logger, + request=request, + )() diff --git a/slack_bolt/lazy_listener/thread_runner.py b/slack_bolt/lazy_listener/thread_runner.py index c6812a46a..3720f9ac5 100644 --- a/slack_bolt/lazy_listener/thread_runner.py +++ b/slack_bolt/lazy_listener/thread_runner.py @@ -1,4 +1,4 @@ -from concurrent.futures.thread import ThreadPoolExecutor +from concurrent.futures import Executor from logging import Logger from typing import Callable @@ -11,12 +11,18 @@ class ThreadLazyListenerRunner(LazyListenerRunner): logger: Logger def __init__( - self, logger: Logger, executor: ThreadPoolExecutor, + 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,) + 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 ab5cdbd79..1717b1a8d 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -1,22 +1,27 @@ from abc import abstractmethod, ABCMeta -from typing import List, Callable, Awaitable, Tuple, Optional +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] + matchers: Sequence[AsyncListenerMatcher] + middleware: Sequence[AsyncMiddleware] ack_function: Callable[..., Awaitable[BoltResponse]] - lazy_functions: List[Callable[..., Awaitable[None]]] + lazy_functions: Sequence[Callable[..., Awaitable[None]]] auto_acknowledgement: bool + 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: @@ -26,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. - :param req: The incoming request - :param resp: Thee current response - :return: A tuple of the processed response and a flag indicating termination + 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} @@ -40,44 +51,39 @@ 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 - ack_function: Callable[..., Awaitable[Optional[BoltResponse]]] - lazy_functions: List[Callable[..., Awaitable[None]]] - 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__( @@ -85,10 +91,12 @@ def __init__( *, app_name: str, ack_function: Callable[..., Awaitable[Optional[BoltResponse]]], - lazy_functions: List[Callable[..., Awaitable[None]]], - matchers: List[AsyncListenerMatcher], - middleware: List[AsyncMiddleware], + lazy_functions: Sequence[Callable[..., Awaitable[None]]], + matchers: Sequence[AsyncListenerMatcher], + middleware: Sequence[AsyncMiddleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -96,11 +104,15 @@ def __init__( self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.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, + self, + *, + request: AsyncBoltRequest, + response: BoltResponse, ) -> Optional[BoltResponse]: return await self.ack_function( **build_async_required_kwargs( @@ -108,6 +120,7 @@ async def run_ack_function( required_arg_names=self.arg_names, request=request, response=response, + this_func=self.ack_function, ) ) diff --git a/slack_bolt/listener/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 index 86e311098..b1a73458e 100644 --- a/slack_bolt/listener/async_listener_error_handler.py +++ b/slack_bolt/listener/async_listener_error_handler.py @@ -1,21 +1,11 @@ -import inspect from abc import ABCMeta, abstractmethod from logging import Logger from typing import Callable, Dict, Any, Awaitable, Optional +from slack_bolt.kwargs_injection.async_utils import build_async_required_kwargs from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse - -from slack_bolt.util.payload_utils import ( - to_options, - to_shortcut, - to_action, - to_view, - to_command, - to_event, - to_message, - to_step, -) +from slack_bolt.util.utils import get_arg_names_of_callable class AsyncListenerErrorHandler(metaclass=ABCMeta): @@ -28,19 +18,19 @@ async def handle( ) -> None: """Handles an unhandled exception. - :param error: The raised exception. - :param request: The request. - :param response: The response. - :return: None + Args: + error: The raised exception. + request: The request. + response: The response. """ raise NotImplementedError() class AsyncCustomListenerErrorHandler(AsyncListenerErrorHandler): - def __init__(self, logger: Logger, func: Callable[..., Awaitable[None]]): + def __init__(self, logger: Logger, func: Callable[..., Awaitable[Optional[BoltResponse]]]): self.func = func self.logger = logger - self.arg_names = inspect.getfullargspec(func).args + self.arg_names = get_arg_names_of_callable(func) async def handle( self, @@ -48,52 +38,20 @@ async def handle( request: AsyncBoltRequest, response: Optional[BoltResponse], ) -> None: - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + 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, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - - await self.func(**kwargs) + 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): 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 index dca9d5705..81f9e6106 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -7,6 +7,12 @@ 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, @@ -15,13 +21,15 @@ ) from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_copy +from slack_bolt.util.utils import create_copy, 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__( @@ -29,11 +37,15 @@ def __init__( logger: Logger, process_before_response: bool, listener_error_handler: AsyncListenerErrorHandler, + listener_start_handler: AsyncListenerStartHandler, + listener_completion_handler: AsyncListenerCompletionHandler, lazy_listener_runner: AsyncLazyListenerRunner, ): self.logger = logger self.process_before_response = process_before_response self.listener_error_handler = listener_error_handler + self.listener_start_handler = listener_start_handler + self.listener_completion_handler = listener_completion_handler self.lazy_listener_runner = lazy_listener_runner async def run( @@ -42,15 +54,15 @@ async def run( response: BoltResponse, listener_name: str, listener: AsyncListener, + starting_time: Optional[float] = None, ) -> Optional[BoltResponse]: ack = request.context.ack - starting_time = time.time() + starting_time = starting_time if starting_time is not None else time.time() if self.process_before_response: if not request.lazy_only: try: - returned_value = await listener.run_ack_function( - request=request, response=response - ) + 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: @@ -62,17 +74,19 @@ async def run( response = BoltResponse(status=500) response.status = 500 await self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) ack.response = response + 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 = lazy_func.__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 - ) + 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: @@ -95,12 +109,13 @@ async def run( # start the listener function asynchronously # NOTE: intentionally async def run_ack_function_asynchronously( - ack: AsyncAck, request: AsyncBoltRequest, response: BoltResponse, + ack: AsyncAck, + request: AsyncBoltRequest, + response: BoltResponse, ): try: - await listener.run_ack_function( - request=request, response=response - ) + 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. @@ -108,24 +123,24 @@ async def run_ack_function_asynchronously( response = BoltResponse(status=500) response.status = 500 if ack.response is not None: # already acknowledged - response = None + response = None # type: ignore[assignment] await self.listener_error_handler.handle( - error=e, request=request, response=response, + 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) - ) + _f: Future = asyncio.ensure_future(run_ack_function_asynchronously(ack, request, response)) for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = lazy_func.__name__ + 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 - ) + 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: @@ -134,7 +149,7 @@ async def run_ack_function_asynchronously( self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: await asyncio.sleep(0.01) if response is None and ack.response is None: @@ -152,27 +167,24 @@ async def run_ack_function_asynchronously( # None for both means no ack() in the listener return None - def _start_lazy_function( - self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest - ) -> None: + def _start_lazy_function(self, lazy_func: Callable[..., Awaitable[None]], request: AsyncBoltRequest) -> None: # Start a lazy function asynchronously - func_name: str = lazy_func.__name__ + 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) - @staticmethod - def _build_lazy_request( - request: AsyncBoltRequest, lazy_func_name: str - ) -> AsyncBoltRequest: - copied_request = create_copy(request) - copied_request.method = "NONE" + 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: + 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 45e552ff6..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, Optional +from typing import Callable, MutableSequence, Optional, Sequence from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.listener_matcher import ListenerMatcher @@ -9,16 +8,18 @@ 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 - ack_function: Callable[..., Optional[BoltResponse]] - lazy_functions: List[Callable[..., None]] - 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__( @@ -26,10 +27,12 @@ def __init__( *, app_name: str, ack_function: Callable[..., Optional[BoltResponse]], - lazy_functions: List[Callable[..., None]], - matchers: List[ListenerMatcher], - middleware: List[Middleware], # type: ignore + lazy_functions: Sequence[Callable[..., None]], + matchers: Sequence[ListenerMatcher], + middleware: Sequence[Middleware], auto_acknowledgement: bool = False, + ack_timeout: int = 3, + base_logger: Optional[Logger] = None, ): self.app_name = app_name self.ack_function = ack_function @@ -37,11 +40,15 @@ def __init__( self.matchers = matchers self.middleware = middleware self.auto_acknowledgement = auto_acknowledgement - self.arg_names = inspect.getfullargspec(ack_function).args - self.logger = get_bolt_app_logger(app_name, self.ack_function) + self.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, + self, + *, + request: BoltRequest, + response: BoltResponse, ) -> Optional[BoltResponse]: return self.ack_function( **build_required_kwargs( @@ -49,5 +56,6 @@ def run_ack_function( required_arg_names=self.arg_names, request=request, response=response, + this_func=self.ack_function, ) ) diff --git a/slack_bolt/listener/listener.py b/slack_bolt/listener/listener.py index aa87f14ef..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,13 +8,19 @@ class Listener(metaclass=ABCMeta): - matchers: List[ListenerMatcher] - middleware: List[Middleware] # type: ignore + matchers: Sequence[ListenerMatcher] + middleware: Sequence[Middleware] ack_function: Callable[..., BoltResponse] - lazy_functions: List[Callable[..., None]] + 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) @@ -23,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 index 7526920b0..7dd6d066b 100644 --- a/slack_bolt/listener/listener_error_handler.py +++ b/slack_bolt/listener/listener_error_handler.py @@ -1,93 +1,57 @@ -import inspect from abc import ABCMeta, abstractmethod from logging import Logger from typing import Callable, Dict, Any, Optional +from slack_bolt.kwargs_injection import build_required_kwargs from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse - -from slack_bolt.util.payload_utils import ( - to_options, - to_shortcut, - to_action, - to_view, - to_command, - to_event, - to_message, - to_step, -) +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], + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], ) -> None: """Handles an unhandled exception. - :param error: The raised exception. - :param request: The request. - :param response: The response. - :return: None + Args: + error: The raised exception. + request: The request. + response: The response. """ raise NotImplementedError() class CustomListenerErrorHandler(ListenerErrorHandler): - def __init__(self, logger: Logger, func: Callable[..., None]): + def __init__(self, logger: Logger, func: Callable[..., Optional[BoltResponse]]): self.func = func self.logger = logger - self.arg_names = inspect.getfullargspec(func).args + self.arg_names = get_arg_names_of_callable(func) def handle( - self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], ): - all_available_args = { - "logger": self.logger, - "error": error, - "client": request.context.client, - "req": request, - "request": request, - "resp": response, - "response": response, - "context": request.context, - "body": request.body, - # payload - "body": request.body, - "options": to_options(request.body), - "shortcut": to_shortcut(request.body), - "action": to_action(request.body), - "view": to_view(request.body), - "command": to_command(request.body), - "event": to_event(request.body), - "message": to_message(request.body), - "step": to_step(request.body), - # utilities - "say": request.context.say, - "respond": request.context.respond, - } - all_available_args["payload"] = ( - all_available_args["options"] - or all_available_args["shortcut"] - or all_available_args["action"] - or all_available_args["view"] - or all_available_args["command"] - or all_available_args["event"] - or all_available_args["message"] - or all_available_args["step"] - or request.body + 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, ) - - kwargs: Dict[str, Any] = { # type: ignore - k: v for k, v in all_available_args.items() if k in self.arg_names # type: ignore - } - found_arg_names = kwargs.keys() - for name in self.arg_names: - if name not in found_arg_names: - self.logger.warning(f"{name} is not a valid argument") - kwargs[name] = None - - self.func(**kwargs) + 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): @@ -95,7 +59,10 @@ def __init__(self, logger: Logger): self.logger = logger def handle( - self, error: Exception, request: BoltRequest, response: Optional[BoltResponse], + self, + error: Exception, + request: BoltRequest, + response: Optional[BoltResponse], ): message = f"Failed to run listener function (error: {error})" self.logger.exception(message) diff --git a/slack_bolt/listener/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 index dfe2a51a3..378ca1bfa 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -1,10 +1,12 @@ import time -from concurrent.futures.thread import ThreadPoolExecutor +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, @@ -13,14 +15,16 @@ ) from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_bolt.util.utils import create_copy +from slack_bolt.util.utils import create_copy, get_name_for_callable class ThreadListenerRunner: logger: Logger process_before_response: bool listener_error_handler: ListenerErrorHandler - listener_executor: ThreadPoolExecutor + listener_start_handler: ListenerStartHandler + listener_completion_handler: ListenerCompletionHandler + listener_executor: Executor lazy_listener_runner: LazyListenerRunner def __init__( @@ -28,30 +32,37 @@ def __init__( logger: Logger, process_before_response: bool, listener_error_handler: ListenerErrorHandler, - listener_executor: ThreadPoolExecutor, + 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( # type: ignore + 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 = time.time() + starting_time = starting_time if starting_time is not None else time.time() if self.process_before_response: if not request.lazy_only: try: - returned_value = listener.run_ack_function( - request=request, response=response + 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: @@ -63,17 +74,22 @@ def run( # type: ignore response = BoltResponse(status=500) response.status = 500 self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) ack.response = response + finally: + self.listener_completion_handler.handle( + request=request, + response=response, + ) for lazy_func in listener.lazy_functions: if request.lazy_function_name: - func_name = lazy_func.__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 - ) + 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: @@ -95,15 +111,21 @@ def run( # type: ignore if not request.lazy_only: # start the listener function asynchronously def run_ack_function_asynchronously(): - nonlocal ack, request, response + nonlocal response try: + self.listener_start_handler.handle( + request=request, + 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, + error=e, + request=request, + response=response, ) else: if response is None: @@ -112,19 +134,24 @@ def run_ack_function_asynchronously(): if ack.response is not None: # already acknowledged response = None self.listener_error_handler.handle( - error=e, request=request, response=response, + error=e, + request=request, + response=response, ) ack.response = response + 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 = lazy_func.__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 - ) + 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: @@ -133,7 +160,7 @@ def run_ack_function_asynchronously(): self._start_lazy_function(lazy_func, request) # await for the completion of ack() in the async listener execution - while ack.response is None and time.time() - starting_time <= 3: + while ack.response is None and time.time() - starting_time <= listener.ack_timeout: time.sleep(0.01) if response is None and ack.response is None: @@ -151,25 +178,25 @@ def run_ack_function_asynchronously(): # None for both means no ack() in the listener return None - def _start_lazy_function( - self, lazy_func: Callable[..., None], request: BoltRequest - ) -> None: + def _start_lazy_function(self, lazy_func: Callable[..., None], request: BoltRequest) -> None: # Start a lazy function asynchronously - func_name: str = lazy_func.__name__ + func_name: str = 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) - @staticmethod - def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: - copied_request = create_copy(request) - copied_request.method = "NONE" + 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: + 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 904697026..3230bb342 100644 --- a/slack_bolt/listener_matcher/async_listener_matcher.py +++ b/slack_bolt/listener_matcher/async_listener_matcher.py @@ -2,6 +2,7 @@ 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): @@ -9,42 +10,43 @@ class AsyncListenerMatcher(metaclass=ABCMeta): async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool: """Matches against the request and returns True if matched. - :param req: The request - :param resp: The response - :return: True if matched. + 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 6c0b2077a..76c12d452 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -1,10 +1,10 @@ -# pytype: skip-file -import inspect -import sys +import re +from logging import Logger -from ..error import BoltError -from ..util.payload_utils import ( +from slack_bolt.error import BoltError +from slack_bolt.request.payload_utils import ( is_block_actions, + is_function, is_global_shortcut, is_message_shortcut, is_attachment_action, @@ -21,13 +21,11 @@ to_action, is_workflow_step_save, ) +from ..logger.messages import error_message_event_type +from ..util.utils import get_arg_names_of_callable -if sys.version_info.major == 3 and sys.version_info.minor <= 6: - from re import _pattern_type as Pattern -else: - from re import Pattern -from typing import Callable, Awaitable, Any -from typing import Union, Optional, Dict +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 @@ -38,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(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) # ------------- @@ -73,45 +79,115 @@ async def async_fun(body: Dict[str, Any]) -> bool: def event( - constraints: Union[str, Pattern, Dict[str, str]], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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) + 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): - event = body["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 + 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) + return build_listener_matcher(func, asyncio, base_logger) + + raise BoltError(f"event ({constraints}: {type(constraints)}) must be dict") - raise BoltError( - f"event ({constraints}: {type(constraints)}) must be any of str, Pattern, and 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 + + +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, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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) @@ -120,7 +196,7 @@ def func(body: Dict[str, Any]) -> bool: and _matches(callback_id, body["event"]["callback_id"]) ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -128,12 +204,14 @@ def func(body: Dict[str, Any]) -> bool: def command( - command: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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) # ------------- @@ -143,14 +221,15 @@ def func(body: Dict[str, Any]) -> 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(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": @@ -158,27 +237,29 @@ def func(body: Dict[str, Any]) -> 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"]: + 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"]: + 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) # ------------- @@ -188,7 +269,8 @@ def func(body: Dict[str, Any]) -> 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)): def func(body: Dict[str, Any]) -> bool: @@ -200,7 +282,7 @@ def func(body: Dict[str, Any]) -> bool: or _workflow_step_edit(constraints, body) ) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) elif "type" in constraints: action_type = constraints["type"] @@ -212,20 +294,16 @@ def func(body: Dict[str, Any]) -> bool: return dialog_submission(constraints["callback_id"], asyncio) if action_type == "dialog_cancellation": return dialog_cancellation(constraints["callback_id"], asyncio) - - # Still in beta - # https://api.slack.com/workflows/steps + # 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: + 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( @@ -238,81 +316,99 @@ def _block_action( action = to_action(body) if isinstance(constraints, (str, Pattern)): action_id = constraints - return _matches(action_id, action["action_id"]) + 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") - block_id_matched = block_id is None or _matches( - block_id, action.get("block_id") - ) - action_id_matched = _matches(constraints["action_id"], action["action_id"]) + action_id = 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( 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] def func(body: Dict[str, Any]) -> bool: return _block_action(constraints, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) -def _attachment_action(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: +def _attachment_action( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_attachment_action(body) and _matches(callback_id, body["callback_id"]) def attachment_action( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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) + return build_listener_matcher(func, asyncio, base_logger) -def _dialog_submission(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: +def _dialog_submission( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_dialog_submission(body) and _matches(callback_id, body["callback_id"]) def dialog_submission( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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], + 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"]: + 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) + return build_listener_matcher(func, asyncio, base_logger) def _workflow_step_edit( - callback_id: Union[str, Pattern], body: Dict[str, Any], + callback_id: Union[str, Pattern], + body: Dict[str, Any], ) -> bool: return is_workflow_step_edit(body) and _matches(callback_id, body["callback_id"]) def workflow_step_edit( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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) + return build_listener_matcher(func, asyncio, base_logger) # ------------------------- @@ -322,7 +418,8 @@ def func(body: Dict[str, Any]) -> 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: @@ -331,42 +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"]: + 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 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"]: + 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 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, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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 is_workflow_step_save(body) and _matches(callback_id, body["view"]["callback_id"]) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) # ------------- @@ -376,50 +471,57 @@ def func(body: Dict[str, Any]) -> 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)): def func(body: Dict[str, Any]) -> bool: - return _block_suggestion(constraints, body) or _dialog_suggestion( - constraints, body - ) + return _block_suggestion(constraints, body) or _dialog_suggestion(constraints, body) - return build_listener_matcher(func, asyncio) + return build_listener_matcher(func, asyncio, base_logger) if "action_id" in constraints: return block_suggestion(constraints["action_id"], asyncio) 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], body: Dict[str, Any],) -> bool: +def _block_suggestion( + action_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_block_suggestion(body) and _matches(action_id, body["action_id"]) def block_suggestion( - action_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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) + return build_listener_matcher(func, asyncio, base_logger) -def _dialog_suggestion(callback_id: Union[str, Pattern], body: Dict[str, Any],) -> bool: +def _dialog_suggestion( + callback_id: Union[str, Pattern], + body: Dict[str, Any], +) -> bool: return is_dialog_suggestion(body) and _matches(callback_id, body["callback_id"]) def dialog_suggestion( - callback_id: Union[str, Pattern], asyncio: bool = False, -) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + 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) + return build_listener_matcher(func, asyncio, base_logger) # ------------------------- @@ -436,6 +538,4 @@ def _matches(str_or_pattern: Union[str, Pattern], input: Optional[str]) -> bool: pattern: Pattern = str_or_pattern 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 30f488efe..2393fa506 100644 --- a/slack_bolt/listener_matcher/listener_matcher.py +++ b/slack_bolt/listener_matcher/listener_matcher.py @@ -9,8 +9,11 @@ class ListenerMatcher(metaclass=ABCMeta): def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: """Matches against the request and returns True if matched. - :param req: The request - :param resp: The response - :return: True if matched. + 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 index 0aee513d2..80e68d022 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -1,23 +1,29 @@ -from typing import Union +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_signing_secret_not_found() -> str: - return ( - "Signing secret not found, so could not initialize the Bolt app." - "Copy your Signing Secret from the Basic Information page " - "and then store it in a new environment variable" - ) - - def error_client_invalid_type() -> str: return "`client` must be a slack_sdk.web.WebClient" @@ -26,15 +32,20 @@ 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." - ) + return "Either an env variable `SLACK_BOT_TOKEN` " "or `token` argument in the constructor is required." def error_unexpected_listener_middleware(middleware_type) -> str: @@ -45,6 +56,29 @@ 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 # ------------------------------- @@ -56,23 +90,268 @@ def warning_client_prioritized_and_token_skipped() -> str: def warning_token_skipped() -> str: return ( - "As you gave `installation_store`/`authorize` as well, `token` will be unused." + "As `installation_store` or `authorize` has been used, " "`token` (or SLACK_BOT_TOKEN env variable) will be ignored." ) -def warning_unhandled_request(req: Union[BoltRequest, "AsyncBoltRequest"]) -> str: # type: ignore - return f"Unhandled request ({req.body})" +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 # ------------------------------- @@ -96,3 +375,11 @@ def debug_running_lazy_listener(func_name: str) -> str: def debug_responding(status: int, body: str, millis: int) -> str: return f'Responding with status: {status} body: "{body}" ({millis} millis)' + + +def debug_return_listener_middleware_response(listener_name: str, status: int, body: str, starting_time: float) -> str: + millis = int((time.time() - starting_time) * 1000) + return ( + "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 09b5338e0..8de07fb88 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -1,11 +1,23 @@ from .ignoring_self_events.async_ignoring_self_events import ( AsyncIgnoringSelfEvents, -) # noqa +) from .request_verification.async_request_verification import ( AsyncRequestVerification, -) # noqa -from .ssl_check.async_ssl_check import AsyncSslCheck # noqa -from .url_verification.async_url_verification import AsyncUrlVerification # noqa +) +from .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, -) # noqa +) +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_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..315ec2a50 --- /dev/null +++ b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py @@ -0,0 +1,56 @@ +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.middleware.async_middleware import AsyncMiddleware +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.response import BoltResponse + + +class AsyncAttachingConversationKwargs(AsyncMiddleware): + + thread_context_store: Optional[AsyncAssistantThreadContextStore] + + def __init__(self, thread_context_store: Optional[AsyncAssistantThreadContextStore] = None): + self.thread_context_store = thread_context_store + + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + event = to_event(req.body) + if event is not None: + if is_assistant_event(req.body): + assistant = AsyncAssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["set_suggested_prompts"] = assistant.set_suggested_prompts + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts = req.context.thread_ts or event.get("ts") + if req.context.channel_id and thread_ts: + req.context["set_status"] = AsyncSetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + ) + req.context["say_stream"] = AsyncSayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts, + ) + return await next() 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..33847fd56 --- /dev/null +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -0,0 +1,50 @@ +from typing import Optional, Callable + +from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore +from slack_bolt.context.say_stream.say_stream import SayStream +from slack_bolt.context.set_status.set_status import SetStatus +from slack_bolt.middleware import Middleware +from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.request.request import BoltRequest +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 not None: + if is_assistant_event(req.body): + assistant = AssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["set_suggested_prompts"] = assistant.set_suggested_prompts + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts = req.context.thread_ts or event.get("ts") + if req.context.channel_id and thread_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + ) + req.context["say_stream"] = SayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts, + ) + return next() 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 6a9d1b696..b5d8264ca 100644 --- a/slack_bolt/middleware/authorization/async_internals.py +++ b/slack_bolt/middleware/authorization/async_internals.py @@ -3,25 +3,20 @@ def _is_url_verification(req: AsyncBoltRequest) -> bool: - return ( - req is not None - and req.body is not None - and req.body.get("type") == "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.body is not None and req.body.get("type") == "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 840a5fc0a..592431f0f 100644 --- a/slack_bolt/middleware/authorization/async_multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/async_multi_teams_authorization.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Callable, Optional, Awaitable from slack_sdk.errors import SlackApiError @@ -5,50 +6,101 @@ 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 .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 -from ...util.async_utils import create_async_web_client class AsyncMultiTeamsAuthorization(AsyncAuthorization): authorize: AsyncAuthorize + user_token_resolution: str - def __init__(self, authorize: AsyncAuthorize): + 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. - :param authorize: The function to authorize incoming requests from Slack. + 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) + 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: - auth_result: Optional[AuthorizeResult] = await self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, + + if _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 - req.context["client"] = create_async_web_client(token) + # As AsyncApp#_init_context() generates a new AsyncWebClient for this request, + # it's safe to modify this instance. + req.context.client.token = token return await next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() + # 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_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 8a307d3da..c783ce4ce 100644 --- a/slack_bolt/middleware/authorization/async_single_team_authorization.py +++ b/slack_bolt/middleware/authorization/async_single_team_authorization.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Callable, Awaitable, Optional from slack_bolt.logger import get_bolt_logger @@ -6,26 +7,47 @@ from slack_bolt.response import BoltResponse from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_sdk.errors import SlackApiError -from .async_internals import _build_error_response, _is_no_auth_required -from .internals import _to_authorize_result +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): + 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) + 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() @@ -42,7 +64,10 @@ async def async_process( 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 3ec2d9d39..85116963c 100644 --- a/slack_bolt/middleware/authorization/internals.py +++ b/slack_bolt/middleware/authorization/internals.py @@ -6,29 +6,55 @@ from slack_bolt.request.request import BoltRequest from slack_bolt.response import BoltResponse +# +# NOTE: this source file intentionally avoids having a reference to +# AsyncBoltRequest, AsyncSlackResponse, and whatever Async-prefixed. +# +# The reason why we do so is to enable developers use sync version of Bolt +# without installing aiohttp library (or any others we may use for async things) +# -def _is_url_verification(req: BoltRequest) -> bool: + +def _is_url_verification(req: Union[BoltRequest, "AsyncBoltRequest"]) -> bool: # type: ignore[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: 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.body is not None - and req.body.get("type") == "url_verification" + and req.body.get("type") == "event_callback" + and req.body.get("event", {}).get("type") in no_auth_test_events ) -def _is_ssl_check(req: BoltRequest) -> bool: - return ( - req is not None and req.body is not None and req.body.get("type") == "ssl_check" - ) +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 _is_no_auth_required(req: BoltRequest) -> bool: - return _is_url_verification(req) or _is_ssl_check(req) +def _is_no_auth_test_call_required(req: 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_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, ) @@ -36,12 +62,13 @@ def _is_bot_token(token: Optional[str]) -> bool: return token is not None and token.startswith("xoxb-") -def _to_authorize_result( # type: ignore - auth_test_result: Union[SlackResponse, "AsyncSlackResponse"], +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"), @@ -50,4 +77,6 @@ def _to_authorize_result( # type: ignore bot_token=token if _is_bot_token(token) else None, user_id=request_user_id or (user_id if not _is_bot_token(token) else None), user_token=token if not _is_bot_token(token) else None, + bot_scopes=oauth_scopes if _is_bot_token(token) else None, + user_scopes=None if _is_bot_token(token) else oauth_scopes, ) diff --git a/slack_bolt/middleware/authorization/multi_teams_authorization.py b/slack_bolt/middleware/authorization/multi_teams_authorization.py index 7efa8ae8c..ee8896ea3 100644 --- a/slack_bolt/middleware/authorization/multi_teams_authorization.py +++ b/slack_bolt/middleware/authorization/multi_teams_authorization.py @@ -1,52 +1,109 @@ +from logging import Logger from typing import Callable, Optional +from slack_sdk.errors import SlackApiError + from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse -from slack_sdk.errors import SlackApiError from .authorization import Authorization -from .internals import _build_error_response, _is_no_auth_required +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 -from ...util.utils import create_web_client class MultiTeamsAuthorization(Authorization): authorize: Authorize + user_token_resolution: str def __init__( - self, *, authorize: Authorize, + 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. - :param authorize: The function to authorize incoming requests from Slack. + 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) + 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: - auth_result: Optional[AuthorizeResult] = self.authorize( - context=req.context, - enterprise_id=req.context.enterprise_id, - team_id=req.context.team_id, - user_id=req.context.user_id, + + 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 - req.context["client"] = create_web_client(token) + # As App#_init_context() generates a new WebClient for this request, + # it's safe to modify this instance. + req.context.client.token = token return next() else: - # Just in case - self.logger.error("auth.test API call result is unexpectedly None") - return _build_error_response() + # 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 8506ebff0..c2bc1488c 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -1,3 +1,4 @@ +from logging import Logger from typing import Callable, Optional from slack_bolt.logger import get_bolt_logger @@ -7,27 +8,59 @@ from slack_sdk.errors import SlackApiError from slack_sdk.web import SlackResponse from .internals import ( - _build_error_response, + _build_user_facing_error_response, _is_no_auth_required, _to_authorize_result, + _is_no_auth_test_call_required, + _build_user_facing_authorize_error_message, ) +from ...authorization import AuthorizeResult class SingleTeamAuthorization(Authorization): - def __init__(self, *, auth_test_result: Optional[SlackResponse] = None): + def __init__( + self, + *, + auth_test_result: Optional[SlackResponse] = None, + base_logger: Optional[Logger] = None, + user_facing_authorize_error_message: Optional[str] = None, + ): """Single-workspace authorization. - :param auth_test_result: The initial `auth.test` API call result. + Args: + auth_test_result: The initial `auth.test` API call result. + base_logger: The base logger """ self.auth_test_result = auth_test_result - self.logger = get_bolt_logger(SingleTeamAuthorization) + self.logger = get_bolt_logger(SingleTeamAuthorization, base_logger=base_logger) + 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() + 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() @@ -44,7 +77,10 @@ def process( 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/__init__.py b/slack_bolt/middleware/ignoring_self_events/__init__.py index b679760bb..1212e6468 100644 --- a/slack_bolt/middleware/ignoring_self_events/__init__.py +++ b/slack_bolt/middleware/ignoring_self_events/__init__.py @@ -1 +1,5 @@ -from .ignoring_self_events import IgnoringSelfEvents # noqa +from .ignoring_self_events import IgnoringSelfEvents + +__all__ = [ + "IgnoringSelfEvents", +] diff --git a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py index 2d43ec372..11a3f40ee 100644 --- a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py @@ -4,6 +4,7 @@ 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): @@ -15,7 +16,14 @@ async def async_process( next: Callable[[], Awaitable[BoltResponse]], ) -> BoltResponse: auth_result = req.context.authorize_result - if self._is_self_event(auth_result, req.context.user_id, req.body): + # 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: diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 0c42141a3..3380636f0 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -1,23 +1,40 @@ import logging -from typing import Callable, Dict, Any +from typing import Callable, Dict, Any, Optional from slack_bolt.authorization import AuthorizeResult from slack_bolt.logger import get_bolt_logger 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): + 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) + 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], + self, + *, + req: BoltRequest, + resp: BoltResponse, + next: Callable[[], BoltResponse], ) -> BoltResponse: auth_result = req.context.authorize_result - if self._is_self_event(auth_result, req.context.user_id, req.body): + # 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: @@ -25,14 +42,27 @@ def process( # ----------------------------------------- - @staticmethod + # 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( - auth_result: AuthorizeResult, user_id: str, body: Dict[str, Any] + 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 == auth_result.bot_user_id + 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): diff --git a/slack_bolt/middleware/message_listener_matches/__init__.py b/slack_bolt/middleware/message_listener_matches/__init__.py index d6825675e..090d5679e 100644 --- a/slack_bolt/middleware/message_listener_matches/__init__.py +++ b/slack_bolt/middleware/message_listener_matches/__init__.py @@ -1 +1,5 @@ -from .message_listener_matches import MessageListenerMatches # noqa +from .message_listener_matches import MessageListenerMatches + +__all__ = [ + "MessageListenerMatches", +] diff --git a/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py index 64eb4dcd3..a3a8c007b 100644 --- a/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/async_message_listener_matches.py @@ -1,5 +1,5 @@ import re -from typing import Callable, Awaitable, Union, Pattern +from typing import Callable, Awaitable, Optional, Sequence, Union, Pattern from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse @@ -16,13 +16,20 @@ 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: text = req.body.get("event", {}).get("text", "") if text: - m = re.search(self.keyword, text) - if m is not None: - req.context["matches"] = m.groups() # tuple + m: 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 diff --git a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py index 834ab7b4b..e5c59990e 100644 --- a/slack_bolt/middleware/message_listener_matches/message_listener_matches.py +++ b/slack_bolt/middleware/message_listener_matches/message_listener_matches.py @@ -1,24 +1,35 @@ import re -from typing import Callable, Pattern, Union +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): # type: ignore +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, 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: text = req.body.get("event", {}).get("text", "") if text: - m = re.search(self.keyword, text) - if m is not None: - req.context["matches"] = m.groups() # tuple + m: 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 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/__init__.py b/slack_bolt/middleware/request_verification/__init__.py index f2e70fda7..a8c564886 100644 --- a/slack_bolt/middleware/request_verification/__init__.py +++ b/slack_bolt/middleware/request_verification/__init__.py @@ -1 +1,5 @@ -from .request_verification import RequestVerification # noqa +from .request_verification import RequestVerification + +__all__ = [ + "RequestVerification", +] diff --git a/slack_bolt/middleware/request_verification/async_request_verification.py b/slack_bolt/middleware/request_verification/async_request_verification.py index c62041b89..3fb9e209b 100644 --- a/slack_bolt/middleware/request_verification/async_request_verification.py +++ b/slack_bolt/middleware/request_verification/async_request_verification.py @@ -7,14 +7,23 @@ 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.body): + if self._can_skip(req.mode, req.body): return await next() body = req.raw_body diff --git a/slack_bolt/middleware/request_verification/request_verification.py b/slack_bolt/middleware/request_verification/request_verification.py index 8c91e65f2..2cf7e361e 100644 --- a/slack_bolt/middleware/request_verification/request_verification.py +++ b/slack_bolt/middleware/request_verification/request_verification.py @@ -1,4 +1,5 @@ -from typing import Callable, Dict, Any +from logging import Logger +from typing import Callable, Dict, Any, Optional from slack_sdk.signature import SignatureVerifier @@ -8,20 +9,31 @@ from slack_bolt.response import BoltResponse -class RequestVerification(Middleware): # type: ignore - def __init__(self, signing_secret: str): +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. + `x-slack-signature`, `x-slack-request-timestamp`, and its body data. - :param signing_secret: The signing secret. + 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.verifier = SignatureVerifier(signing_secret=signing_secret) - self.logger = get_bolt_logger(RequestVerification) + self.logger = get_bolt_logger(RequestVerification, base_logger=base_logger) def process( - self, *, 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 self._can_skip(req.body): + if self._can_skip(req.mode, req.body): return next() body = req.raw_body @@ -36,8 +48,8 @@ def process( # ----------------------------------------- @staticmethod - def _can_skip(body: Dict[str, Any]) -> bool: - return body is not None and body.get("ssl_check") == "1" + def _can_skip(mode: str, body: Dict[str, Any]) -> bool: + return mode == "socket_mode" or (body is not None and body.get("ssl_check") == "1") @staticmethod def _build_error_response() -> BoltResponse: @@ -45,6 +57,5 @@ def _build_error_response() -> BoltResponse: def _debug_log_error(self, signature, timestamp, body) -> None: self.logger.info( - "Invalid request signature detected " - f"(signature: {signature}, timestamp: {timestamp}, body: {body})" + "Invalid request signature detected " f"(signature: {signature}, timestamp: {timestamp}, body: {body})" ) diff --git a/slack_bolt/middleware/ssl_check/__init__.py b/slack_bolt/middleware/ssl_check/__init__.py index 3b2a137d7..33a3708d5 100644 --- a/slack_bolt/middleware/ssl_check/__init__.py +++ b/slack_bolt/middleware/ssl_check/__init__.py @@ -1 +1,5 @@ -from .ssl_check import SslCheck # noqa +from .ssl_check import SslCheck + +__all__ = [ + "SslCheck", +] diff --git a/slack_bolt/middleware/ssl_check/async_ssl_check.py b/slack_bolt/middleware/ssl_check/async_ssl_check.py index a8a62c5e3..5b806a4bc 100644 --- a/slack_bolt/middleware/ssl_check/async_ssl_check.py +++ b/slack_bolt/middleware/ssl_check/async_ssl_check.py @@ -12,6 +12,9 @@ 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.body): diff --git a/slack_bolt/middleware/ssl_check/ssl_check.py b/slack_bolt/middleware/ssl_check/ssl_check.py index 43d4eca5d..88c5105ef 100644 --- a/slack_bolt/middleware/ssl_check/ssl_check.py +++ b/slack_bolt/middleware/ssl_check/ssl_check.py @@ -1,4 +1,5 @@ -from typing import Callable +from logging import Logger +from typing import Callable, Optional from slack_bolt.logger import get_bolt_logger from slack_bolt.middleware.middleware import Middleware @@ -6,19 +7,35 @@ from slack_bolt.response import BoltResponse -class SslCheck(Middleware): # type: ignore - def __init__(self, verification_token: str = None): - """Handles ssl_check requests. +class SslCheck(Middleware): + verification_token: Optional[str] + logger: Logger - Refer to https://api.slack.com/interactivity/slash-commands for details. - :param verification_token: The verification token to check - (optional as it's already deprecated - https://api.slack.com/authentication/verifying-requests-from-slack#verification_token_deprecation) - """ + 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) + self.logger = get_bolt_logger(SslCheck, base_logger=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: if self._is_ssl_check_request(req.body): if self._verify_token_if_needed(req.body): diff --git a/slack_bolt/middleware/url_verification/__init__.py b/slack_bolt/middleware/url_verification/__init__.py index 73c2e2952..4fc29dfce 100644 --- a/slack_bolt/middleware/url_verification/__init__.py +++ b/slack_bolt/middleware/url_verification/__init__.py @@ -1 +1,5 @@ -from .url_verification import UrlVerification # noqa +from .url_verification import UrlVerification + +__all__ = [ + "UrlVerification", +] diff --git a/slack_bolt/middleware/url_verification/async_url_verification.py b/slack_bolt/middleware/url_verification/async_url_verification.py index 91b4f2b63..2371bc3a4 100644 --- a/slack_bolt/middleware/url_verification/async_url_verification.py +++ b/slack_bolt/middleware/url_verification/async_url_verification.py @@ -1,4 +1,5 @@ -from typing import Callable, Awaitable +from logging import Logger +from typing import Callable, Awaitable, Optional from slack_bolt.logger import get_bolt_logger from .url_verification import UrlVerification @@ -8,8 +9,8 @@ class AsyncUrlVerification(UrlVerification, AsyncMiddleware): - def __init__(self): - self.logger = get_bolt_logger(AsyncUrlVerification) + def __init__(self, base_logger: Optional[Logger] = None): + self.logger = get_bolt_logger(AsyncUrlVerification, base_logger=base_logger) async def async_process( self, diff --git a/slack_bolt/middleware/url_verification/url_verification.py b/slack_bolt/middleware/url_verification/url_verification.py index 04e95d2fd..7505c9c15 100644 --- a/slack_bolt/middleware/url_verification/url_verification.py +++ b/slack_bolt/middleware/url_verification/url_verification.py @@ -1,4 +1,5 @@ -from typing import Callable +from logging import Logger +from typing import Callable, Optional from slack_bolt.logger import get_bolt_logger from slack_bolt.middleware.middleware import Middleware @@ -6,16 +7,26 @@ from slack_bolt.response import BoltResponse -class UrlVerification(Middleware): # type: ignore - def __init__(self): +class UrlVerification(Middleware): + def __init__(self, base_logger: Optional[Logger] = None): """Handles url_verification requests. - Refer to https://api.slack.com/events/url_verification for details. + Refer to https://docs.slack.dev/reference/events/url_verification/ for details. + + Args: + base_logger: The base logger """ - self.logger = get_bolt_logger(UrlVerification) + self.logger = get_bolt_logger(UrlVerification, base_logger=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: if self._is_url_verification_request(req.body): return self._build_success_response(req.body) 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 index 6acdbd3aa..e1c2b2e4c 100644 --- a/slack_bolt/oauth/async_callback_options.py +++ b/slack_bolt/oauth/async_callback_options.py @@ -1,6 +1,6 @@ import logging from logging import Logger -from typing import Optional, Callable, Awaitable +from typing import Optional, Callable, Awaitable, TYPE_CHECKING from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils from slack_sdk.oauth.installation_store import Installation @@ -9,28 +9,35 @@ 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__( # type: ignore + def __init__( self, *, request: AsyncBoltRequest, installation: Installation, settings: "AsyncOAuthSettings", + default: "AsyncCallbackOptions", ): """The arguments for a success function. - :param request: The request. - :param installation: The installation data. - :param settings: The settings for OAuth flow. + 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__( # type: ignore + def __init__( self, *, request: AsyncBoltRequest, @@ -38,20 +45,24 @@ def __init__( # type: ignore error: Optional[Exception] = None, suggested_status_code: int, settings: "AsyncOAuthSettings", + default: "AsyncCallbackOptions", ): """The arguments for a failure function. - :param request: The request. - :param reason: The response. - :param error: An exception if exists. - :param suggested_status_code: The recommended HTTP status code for the failure. - :param settings: The settings for OAuth flow. + 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: @@ -92,10 +103,13 @@ def __init__( async def _success_handler(self, args: AsyncSuccessArgs) -> BoltResponse: return self._response_builder._build_callback_success_response( - request=args.request, installation=args.installation, + request=args.request, + installation=args.installation, ) async def _failure_handler(self, args: AsyncFailureArgs) -> BoltResponse: return self._response_builder._build_callback_failure_response( - request=args.request, reason=args.reason, status=args.suggested_status_code, + request=args.request, + reason=args.reason, + status=args.suggested_status_code, ) diff --git a/slack_bolt/oauth/async_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 3ac865ca5..e7f0fa724 100644 --- a/slack_bolt/oauth/async_oauth_flow.py +++ b/slack_bolt/oauth/async_oauth_flow.py @@ -1,9 +1,10 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict, Callable, Awaitable +from typing import Optional, Dict, Callable, Awaitable, Sequence from slack_bolt.error import BoltError +from slack_bolt.logger.messages import error_oauth_settings_invalid_type_async from slack_bolt.oauth.async_callback_options import ( AsyncCallbackOptions, DefaultAsyncCallbackOptions, @@ -11,6 +12,7 @@ AsyncFailureArgs, ) from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from slack_bolt.oauth.internals import _build_default_install_page_html from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_sdk.errors import SlackApiError @@ -34,18 +36,6 @@ class AsyncOAuthFlow: success_handler: Callable[[AsyncSuccessArgs], Awaitable[BoltResponse]] failure_handler: Callable[[AsyncFailureArgs], Awaitable[BoltResponse]] - @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 - def __init__( self, *, @@ -55,29 +45,48 @@ def __init__( ): """The module to run the Slack app installation flow (OAuth flow). - :param client: The AsyncWebClient. - :param logger: The logger. - :param settings: OAuth settings to configure this module. + 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 - self.settings.logger = self._logger + + 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 = DefaultAsyncCallbackOptions( - logger=logger, - state_utils=self.settings.state_utils, - redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, - ) + settings.callback_options = self.default_callback_options self.success_handler = settings.callback_options.success self.failure_handler = settings.callback_options.failure + @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 # ----------------------------- @@ -90,8 +99,8 @@ def sqlite3( authorization_url: Optional[str] = None, client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: Optional[str] = None, @@ -103,6 +112,7 @@ def sqlite3( # state parameter related configurations state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + installation_store_bot_only: bool = False, client: Optional[AsyncWebClient] = None, logger: Optional[Logger] = None, ) -> "AsyncOAuthFlow": @@ -112,6 +122,16 @@ def sqlite3( 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, @@ -124,21 +144,16 @@ def sqlite3( user_scopes=user_scopes, redirect_uri=redirect_uri, # Handler configuration - install_path=install_path, - redirect_uri_path=redirect_uri_path, + 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=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, - ), + installation_store=installation_store, + installation_store_bot_only=installation_store_bot_only, # state parameter related configurations - state_store=SQLite3OAuthStateStore( - database=database, - expiration_seconds=state_expiration_seconds, - logger=logger, - ), + state_store=state_store, state_cookie_name=state_cookie_name, state_expiration_seconds=state_expiration_seconds, ), @@ -149,8 +164,31 @@ def sqlite3( # ----------------------------- async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: - state = await self.issue_new_state(request) - return await self.build_authorize_url_redirection(request, state) + 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 @@ -158,19 +196,21 @@ async def handle_installation(self, request: AsyncBoltRequest) -> BoltResponse: async def issue_new_state(self, request: AsyncBoltRequest) -> str: return await self.settings.state_store.async_issue() - async def build_authorize_url_redirection( - self, request: AsyncBoltRequest, state: str - ) -> BoltResponse: - return BoltResponse( - status=302, - headers={ - "Location": [self.settings.authorize_url_generator.generate(state)], - "Set-Cookie": [ - self.settings.state_utils.build_set_cookie_for_new_state(state) - ], - }, + async def build_authorize_url(self, state: str, request: AsyncBoltRequest) -> str: + 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 # ----------------------------- @@ -183,34 +223,38 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: return await self.failure_handler( AsyncFailureArgs( request=request, - reason=error, # type: ignore + reason=error, suggested_status_code=200, settings=self.settings, + default=self.default_callback_options, ) ) # state parameter verification - state: Optional[str] = request.query.get("state", [None])[0] - if not self.settings.state_utils.is_valid_browser(state, request.headers): - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_browser", - suggested_status_code=400, - settings=self.settings, + if self.settings.state_validation_enabled is True: + state: Optional[str] = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return await self.failure_handler( + AsyncFailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = await self.settings.state_store.async_consume(state) - if not valid_state_consumed: - return await self.failure_handler( - AsyncFailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, + 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] @@ -221,6 +265,7 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: reason="missing_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -233,6 +278,7 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: reason="invalid_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -247,13 +293,17 @@ async def handle_callback(self, request: AsyncBoltRequest) -> BoltResponse: error=err, suggested_status_code=500, settings=self.settings, + default=self.default_callback_options, ) ) # display a successful completion page to the end-user return await self.success_handler( AsyncSuccessArgs( - request=request, installation=installation, settings=self.settings, + request=request, + installation=installation, + settings=self.settings, + default=self.default_callback_options, ) ) @@ -268,49 +318,53 @@ async def run_installation(self, code: str) -> Optional[Installation]: client_secret=self.settings.client_secret, redirect_uri=self.settings.redirect_uri, # can be None ) - installed_enterprise: Dict[str, str] = oauth_response.get( - "enterprise" - ) or {} + installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {} + is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False installed_team: Dict[str, str] = oauth_response.get("team") or {} installer: Dict[str, str] = oauth_response.get("authed_user") or {} - incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook" - ) or {} + incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {} bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response 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"), # comma-separated string - user_id=installer.get("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"), # comma-separated string + 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", None - ), + 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: AsyncBoltRequest, installation: Installation - ): + async def store_installation(self, request: AsyncBoltRequest, installation: Installation): # may raise BoltError 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 index 01b2e43a5..e8513b3d3 100644 --- a/slack_bolt/oauth/async_oauth_settings.py +++ b/slack_bolt/oauth/async_oauth_settings.py @@ -1,14 +1,13 @@ import logging import os from logging import Logger -from typing import List, Optional +from typing import Optional, Sequence, Union from slack_sdk.oauth import ( OAuthStateUtils, AuthorizeUrlGenerator, RedirectUriPageRenderer, ) -from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, ) @@ -20,27 +19,33 @@ AsyncAuthorize, ) from slack_bolt.error import BoltError -from slack_bolt.oauth.callback_options import CallbackOptions +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[List[str]] - user_scopes: Optional[List[str]] + scopes: Optional[Sequence[str]] + user_scopes: Optional[Sequence[str]] redirect_uri: Optional[str] # Handler configuration install_path: str + install_page_rendering_enabled: bool redirect_uri_path: str - callback_options: Optional[CallbackOptions] = None + callback_options: Optional[AsyncCallbackOptions] = None success_url: Optional[str] failure_url: Optional[str] authorization_url: str # default: https://slack.com/oauth/v2/authorize # 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 @@ -57,19 +62,24 @@ def __init__( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[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, + 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, @@ -78,59 +88,76 @@ def __init__( ): """The settings for Slack App installation (OAuth flow). - :param client_id: Check the value in Settings > Basic Information > App Credentials - :param client_secret: Check the value in Settings > Basic Information > App Credentials - :param scopes: Check the value in Settings > Manage Distribution - :param user_scopes: Check the value in Settings > Manage Distribution - :param redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs - :param install_path: The endpoint to start an OAuth flow (Default: /slack/install) - :param redirect_uri_path: The path of Redirect URL (Default: /slack/oauth_redirect) - :param callback_options: Give success/failure functions f you want to customize callback functions. - :param success_url: Set a complete URL if you want to redirect end-users when an installation completes. - :param failure_url: Set a complete URL if you want to redirect end-users when an installation fails. - :param authorization_url: Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize - :param installation_store: Specify the instance of InstallationStore (Default: FileInstallationStore) - :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) - :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) - :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) - :param logger: The logger that will be used internally + 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 - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None - ) - if self.client_id is None or self.client_secret is None: + 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.scopes = scopes or os.environ.get("SLACK_SCOPES", "").split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) self.redirect_uri = redirect_uri or os.environ.get("SLACK_REDIRECT_URI") # Handler configuration - self.install_path = install_path or os.environ.get( - "SLACK_INSTALL_PATH", "/slack/install" - ) - self.redirect_uri_path = redirect_uri_path or os.environ.get( - "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" - ) + self.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" - ) + self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management - self.installation_store = installation_store or FileInstallationStore( - client_id=client_id - ) + 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, installation_store=self.installation_store, + 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, + expiration_seconds=state_expiration_seconds, + client_id=client_id, ) self.state_cookie_name = state_cookie_name self.state_expiration_seconds = state_expiration_seconds diff --git a/slack_bolt/oauth/callback_options.py b/slack_bolt/oauth/callback_options.py index e1368dae1..09584a365 100644 --- a/slack_bolt/oauth/callback_options.py +++ b/slack_bolt/oauth/callback_options.py @@ -1,6 +1,6 @@ import logging from logging import Logger -from typing import Optional, Callable +from typing import Optional, Callable, TYPE_CHECKING from slack_sdk.oauth import RedirectUriPageRenderer, OAuthStateUtils from slack_sdk.oauth.installation_store import Installation @@ -9,28 +9,35 @@ 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__( # type: ignore + def __init__( self, *, request: BoltRequest, installation: Installation, settings: "OAuthSettings", + default: "CallbackOptions", ): """The arguments for a success function. - :param request: The request. - :param installation: The installation data. - :param settings: The settings for OAuth flow. + 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__( # type: ignore + def __init__( self, *, request: BoltRequest, @@ -38,20 +45,24 @@ def __init__( # type: ignore error: Optional[Exception] = None, suggested_status_code: int, settings: "OAuthSettings", + default: "CallbackOptions", ): """The arguments for a failure function. - :param request: The request. - :param reason: The response. - :param error: An exception if exists. - :param suggested_status_code: The recommended HTTP status code for the failure. - :param settings: The settings for OAuth flow. + 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: @@ -65,8 +76,9 @@ def __init__( ): """The configurations for OAuth flow. - :param success: A handler for successful installation. - :param failure: A handler for any types of installation failures. + Args: + success: A handler for successful installation. + failure: A handler for any types of installation failures. """ self.success = success self.failure = failure @@ -97,10 +109,13 @@ def __init__( def _success_handler(self, args: SuccessArgs) -> BoltResponse: return self._response_builder._build_callback_success_response( - request=args.request, installation=args.installation, + request=args.request, + installation=args.installation, ) def _failure_handler(self, args: FailureArgs) -> BoltResponse: return self._response_builder._build_callback_failure_response( - request=args.request, reason=args.reason, status=args.suggested_status_code, + request=args.request, + reason=args.reason, + status=args.suggested_status_code, ) diff --git a/slack_bolt/oauth/internals.py b/slack_bolt/oauth/internals.py index 645f350a3..05959817a 100644 --- a/slack_bolt/oauth/internals.py +++ b/slack_bolt/oauth/internals.py @@ -1,11 +1,16 @@ +import html from logging import Logger -from typing import Optional, Union +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: @@ -20,47 +25,121 @@ def __init__( self._state_utils = state_utils self._redirect_uri_page_renderer = redirect_uri_page_renderer - def _build_callback_success_response( # type: ignore + def _build_callback_success_response( self, - request: Union[BoltRequest, "AsyncBoltRequest"], + 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) - html = self._redirect_uri_page_renderer.render_success_page( - app_id=installation.app_id, team_id=installation.team_id, + 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", - "Content-Length": len(html), "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + body=page_content, ) - def _build_callback_failure_response( # type: ignore + def _build_callback_failure_response( self, - request: Union[BoltRequest, "AsyncBoltRequest"], + 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})" - ) + 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) + # 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", - "Content-Length": len(html), "Set-Cookie": self._state_utils.build_set_cookie_for_deletion(), }, - body=html, + 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 8d37709d0..542860848 100644 --- a/slack_bolt/oauth/oauth_flow.py +++ b/slack_bolt/oauth/oauth_flow.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import Optional, List, Dict, Callable +from typing import Optional, Dict, Callable, Sequence from slack_bolt.error import BoltError from slack_bolt.oauth.callback_options import ( @@ -10,6 +10,7 @@ DefaultCallbackOptions, CallbackOptions, ) +from slack_bolt.oauth.internals import _build_default_install_page_html from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest @@ -34,18 +35,6 @@ class OAuthFlow: success_handler: Callable[[SuccessArgs], BoltResponse] failure_handler: Callable[[FailureArgs], BoltResponse] - @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 - def __init__( self, *, @@ -55,29 +44,44 @@ def __init__( ): """The module to run the Slack app installation flow (OAuth flow). - :param client: The WebClient. - :param logger: The logger. - :param settings: OAuth settings to configure this module. + 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 - self.settings.logger = self._logger + 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 = DefaultCallbackOptions( - logger=logger, - state_utils=self.settings.state_utils, - redirect_uri_page_renderer=self.settings.redirect_uri_page_renderer, - ) + settings.callback_options = self.default_callback_options self.success_handler = settings.callback_options.success self.failure_handler = settings.callback_options.failure + @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 # ----------------------------- @@ -89,8 +93,8 @@ def sqlite3( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[Sequence[str]] = None, + user_scopes: Optional[Sequence[str]] = None, redirect_uri: Optional[str] = None, # Handler configuration install_path: Optional[str] = None, @@ -103,6 +107,8 @@ def sqlite3( # state parameter related configurations state_cookie_name: str = OAuthStateUtils.default_cookie_name, state_expiration_seconds: int = OAuthStateUtils.default_expiration_seconds, + installation_store_bot_only: bool = False, + token_rotation_expiration_minutes: int = 120, client: Optional[WebClient] = None, logger: Optional[Logger] = None, ) -> "OAuthFlow": @@ -112,6 +118,16 @@ def sqlite3( 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, @@ -123,22 +139,18 @@ def sqlite3( user_scopes=user_scopes, redirect_uri=redirect_uri, # Handler configuration - install_path=install_path, - redirect_uri_path=redirect_uri_path, + 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=SQLite3InstallationStore( - database=database, client_id=client_id, logger=logger, - ), + 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=SQLite3OAuthStateStore( - database=database, - expiration_seconds=state_expiration_seconds, - logger=logger, - ), + state_store=state_store, state_cookie_name=state_cookie_name, state_expiration_seconds=state_expiration_seconds, ), @@ -149,8 +161,32 @@ 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 @@ -158,19 +194,21 @@ def handle_installation(self, request: BoltRequest) -> BoltResponse: def issue_new_state(self, request: BoltRequest) -> str: return self.settings.state_store.issue() - def build_authorize_url_redirection( - self, request: BoltRequest, state: str - ) -> BoltResponse: - return BoltResponse( - status=302, - headers={ - "Location": [self.settings.authorize_url_generator.generate(state)], - "Set-Cookie": [ - self.settings.state_utils.build_set_cookie_for_new_state(state) - ], - }, + def build_authorize_url(self, state: str, request: BoltRequest) -> str: + 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,31 +224,35 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: 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.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, + if self.settings.state_validation_enabled is True: + state = request.query.get("state", [None])[0] + if not self.settings.state_utils.is_valid_browser(state, request.headers): + return self.failure_handler( + FailureArgs( + request=request, + reason="invalid_browser", + suggested_status_code=400, + settings=self.settings, + default=self.default_callback_options, + ) ) - ) - valid_state_consumed = self.settings.state_store.consume(state) - if not valid_state_consumed: - return self.failure_handler( - FailureArgs( - request=request, - reason="invalid_state", - suggested_status_code=401, - settings=self.settings, + 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] @@ -221,6 +263,7 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: reason="missing_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -233,6 +276,7 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: reason="invalid_code", suggested_status_code=401, settings=self.settings, + default=self.default_callback_options, ) ) @@ -247,13 +291,17 @@ def handle_callback(self, request: BoltRequest) -> BoltResponse: error=err, suggested_status_code=500, settings=self.settings, + default=self.default_callback_options, ) ) # display a successful completion page to the end-user return self.success_handler( SuccessArgs( - request=request, installation=installation, settings=self.settings, + request=request, + installation=installation, + settings=self.settings, + default=self.default_callback_options, ) ) @@ -268,44 +316,50 @@ def run_installation(self, code: str) -> Optional[Installation]: client_secret=self.settings.client_secret, redirect_uri=self.settings.redirect_uri, # can be None ) - installed_enterprise: Dict[str, str] = oauth_response.get( - "enterprise" - ) or {} + installed_enterprise: Dict[str, str] = oauth_response.get("enterprise") or {} + is_enterprise_install: bool = oauth_response.get("is_enterprise_install") or False installed_team: Dict[str, str] = oauth_response.get("team") or {} installer: Dict[str, str] = oauth_response.get("authed_user") or {} - incoming_webhook: Dict[str, str] = oauth_response.get( - "incoming_webhook" - ) or {} + incoming_webhook: Dict[str, str] = oauth_response.get("incoming_webhook") or {} bot_token: Optional[str] = oauth_response.get("access_token") # NOTE: oauth.v2.access doesn't include bot_id in response 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"), # comma-separated string - user_id=installer.get("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"), # comma-separated string + 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", None - ), + 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 diff --git a/slack_bolt/oauth/oauth_settings.py b/slack_bolt/oauth/oauth_settings.py index 3cc82c834..ec2727f75 100644 --- a/slack_bolt/oauth/oauth_settings.py +++ b/slack_bolt/oauth/oauth_settings.py @@ -1,7 +1,7 @@ import logging import os from logging import Logger -from typing import List, Optional +from typing import Optional, Sequence, Union from slack_sdk.oauth import ( OAuthStateStore, @@ -10,11 +10,11 @@ AuthorizeUrlGenerator, RedirectUriPageRenderer, ) -from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore from slack_bolt.authorization.authorize import Authorize, InstallationStoreAuthorize from slack_bolt.error import BoltError +from slack_bolt.oauth.internals import get_or_create_default_installation_store from slack_bolt.oauth.callback_options import CallbackOptions @@ -22,11 +22,12 @@ class OAuthSettings: # OAuth flow parameters/credentials client_id: str client_secret: str - scopes: Optional[List[str]] - user_scopes: Optional[List[str]] + scopes: Optional[Sequence[str]] + user_scopes: Optional[Sequence[str]] redirect_uri: Optional[str] # Handler configuration install_path: str + install_page_rendering_enabled: bool redirect_uri_path: str callback_options: Optional[CallbackOptions] = None success_url: Optional[str] @@ -34,8 +35,12 @@ class OAuthSettings: 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 @@ -52,11 +57,12 @@ def __init__( # OAuth flow parameters/credentials client_id: Optional[str] = None, # required client_secret: Optional[str] = None, # required - scopes: Optional[List[str]] = None, - user_scopes: Optional[List[str]] = None, + scopes: Optional[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, @@ -64,7 +70,11 @@ def __init__( 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, @@ -73,58 +83,74 @@ def __init__( ): """The settings for Slack App installation (OAuth flow). - :param client_id: Check the value in Settings > Basic Information > App Credentials - :param client_secret: Check the value in Settings > Basic Information > App Credentials - :param scopes: Check the value in Settings > Manage Distribution - :param user_scopes: Check the value in Settings > Manage Distribution - :param redirect_uri: Check the value in Features > OAuth & Permissions > Redirect URLs - :param install_path: The endpoint to start an OAuth flow (Default: /slack/install) - :param redirect_uri_path: The path of Redirect URL (Default: /slack/oauth_redirect) - :param callback_options: Give success/failure functions f you want to customize callback functions. - :param success_url: Set a complete URL if you want to redirect end-users when an installation completes. - :param failure_url: Set a complete URL if you want to redirect end-users when an installation fails. - :param authorization_url: Set a URL if you want to customize the URL https://slack.com/oauth/v2/authorize - :param installation_store: Specify the instance of InstallationStore (Default: FileInstallationStore) - :param state_store: Specify the instance of InstallationStore (Default: FileOAuthStateStore) - :param state_cookie_name: The cookie name that is set for installers' browser. (Default: slack-app-oauth-state) - :param state_expiration_seconds: The seconds that the state value is alive (Default: 600 seconds) - :param logger: The logger that will be used internally + 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 """ - self.client_id = client_id or os.environ.get("SLACK_CLIENT_ID") - self.client_secret = client_secret or os.environ.get( - "SLACK_CLIENT_SECRET", None - ) - if self.client_id is None or self.client_secret is None: + 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 or os.environ.get("SLACK_SCOPES", "").split(",") - self.user_scopes = user_scopes or os.environ.get("SLACK_USER_SCOPES", "").split( - "," - ) + 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.redirect_uri_path = redirect_uri_path or os.environ.get( - "SLACK_REDIRECT_URI_PATH", "/slack/oauth_redirect" - ) + 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" - ) + self.authorization_url = authorization_url or "https://slack.com/oauth/v2/authorize" # Installation Management - self.installation_store = installation_store or FileInstallationStore( - client_id=client_id - ) + 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, installation_store=self.installation_store, + 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, + expiration_seconds=state_expiration_seconds, + client_id=client_id, ) self.state_cookie_name = state_cookie_name self.state_expiration_seconds = state_expiration_seconds diff --git a/slack_bolt/request/__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 79a898086..73891446e 100644 --- a/slack_bolt/request/async_request.py +++ b/slack_bolt/request/async_request.py @@ -1,50 +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_body, build_normalized_headers, extract_content_type, + error_message_raw_body_required_in_http_mode, ) class AsyncBoltRequest: raw_body: str body: Dict[str, Any] - query: Dict[str, List[str]] - headers: Dict[str, List[str]] + query: Dict[str, Sequence[str]] + headers: Dict[str, Sequence[str]] content_type: Optional[str] context: AsyncBoltContext lazy_only: bool 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" ): """Request to a Bolt app. - :param body: The raw request body (only plain text is supported) - :param query: The query string data in any data format. - :param headers: The request headers. - :param context: The context in this request. + 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") """ - self.raw_body = body + + 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.body = parse_body(self.raw_body, self.content_type) - self.context = build_async_context( - AsyncBoltContext(context if context else {}), self.body + + 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, ) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] - self.lazy_function_name = self.headers.get( - "x-slack-bolt-lazy-function-name", [None] - )[0] diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 4a14a46d9..15d1e7367 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -1,29 +1,25 @@ 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)})") @@ -31,114 +27,291 @@ def parse_query( 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("{"): + if (content_type is not None and content_type == "application/json") or body.startswith("{"): return json.loads(body) else: - if "payload" in body: - params = dict(parse_qsl(body)) - if "payload" in params: - return json.loads(params.get("payload")) + 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)) + 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") - if "event" in payload: + 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"]["team_id"] + if isinstance(payload.get("view"), dict): + return payload["view"]["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 "item" in payload: + 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_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 build_context(context: BoltContext, payload: Dict[str, Any],) -> BoltContext: - enterprise_id = extract_enterprise_id(payload) +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() @@ -147,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..1ebf70d4f --- /dev/null +++ b/slack_bolt/request/payload_utils.py @@ -0,0 +1,257 @@ +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_event(body) and body["event"]["type"] == "message": + 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_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_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im" + return False + + +def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: + if is_event(body): + return ( + is_message_event_in_assistant_thread(body) + and body["event"].get("subtype") in (None, "file_share") + and body["event"].get("thread_ts") is not None + and body["event"].get("bot_id") is None + ) + return False + + +def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: + if is_event(body): + return ( + is_message_event_in_assistant_thread(body) + and body["event"].get("subtype") 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_event(body): + return ( + is_message_event_in_assistant_thread(body) + and not is_user_message_event_in_assistant_thread(body) + and ( + _is_other_message_sub_event(body["event"].get("message")) + or _is_other_message_sub_event(body["event"].get("previous_message")) + ) + ) + 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 71da15316..2a418a33f 100644 --- a/slack_bolt/request/request.py +++ b/slack_bolt/request/request.py @@ -1,48 +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_body, build_normalized_headers, build_context, extract_content_type, + error_message_raw_body_required_in_http_mode, ) class BoltRequest: raw_body: str - query: Dict[str, List[str]] - headers: Dict[str, List[str]] + query: Dict[str, Sequence[str]] + headers: Dict[str, Sequence[str]] content_type: Optional[str] body: Dict[str, Any] context: BoltContext 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" ): """Request to a Bolt app. - :param body: The raw request body (only plain text is supported) - :param query: The query string data in any data format. - :param headers: The request headers. - :param context: The context in this request. + 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") """ - self.raw_body = body + 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.body = parse_body(self.raw_body, self.content_type) + + if isinstance(body, str): + self.body = parse_body(self.raw_body, self.content_type) + elif isinstance(body, dict): + self.body = body + else: + self.body = {} + self.context = build_context(BoltContext(context if context else {}), self.body) - self.lazy_only = self.headers.get("x-slack-bolt-lazy-only", [False])[0] - self.lazy_function_name = self.headers.get( - "x-slack-bolt-lazy-function-name", [None] - )[0] + self.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 1c6014516..227b4fa22 100644 --- a/slack_bolt/response/response.py +++ b/slack_bolt/response/response.py @@ -1,29 +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. - :param status: HTTP status code - :param body: The response body (plain text response is supported) - :param headers: The response headers. + 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: @@ -47,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/payload_utils.py b/slack_bolt/util/payload_utils.py deleted file mode 100644 index ef3dffc73..000000000 --- a/slack_bolt/util/payload_utils.py +++ /dev/null @@ -1,240 +0,0 @@ -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_event(body) and body["event"]["type"] == "message": - return to_event(body) - return None - - -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"] - ) - - -# ------------------- -# 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" - - -# ------------------- -# Workflow Steps -# ------------------- - - -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/util/utils.py b/slack_bolt/util/utils.py index 2289f85d4..9ee313821 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -1,6 +1,8 @@ import copy +import inspect import sys -from typing import Optional, Union, Dict, List, Any +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 @@ -9,11 +11,15 @@ 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]: +def convert_to_dict_list(objects: Sequence[Union[Dict, JsonObject]]) -> Sequence[Dict]: return [convert_to_dict(elm) for elm in objects] @@ -26,16 +32,65 @@ def convert_to_dict(obj: Union[Dict, JsonObject]) -> Dict: def create_copy(original: Any) -> Any: - if sys.version_info.major == 3 and sys.version_info.minor <= 6: - # NOTE: Unfortunately, copy.deepcopy doesn't work in Python 3.6.5. - # -------------------- - # > rv = reductor(4) - # E TypeError: can't pickle _thread.RLock objects - # ../../.pyenv/versions/3.6.10/lib/python3.6/copy.py:169: TypeError - # -------------------- - # As a workaround, this operation uses shallow copies in Python 3.6. - # If your code modifies the shared data in threads / async functions, race conditions may arise. - # Please consider upgrading Python major version to 3.7+ if you encounter some issues due to this. - return copy.copy(original) + 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 copy.deepcopy(original) + 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 f8184dc12..ebda7dafb 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1,3 @@ -__version__ = "0.9.2b0" +"""Check the latest version at https://pypi.org/project/slack-bolt/""" + +__version__ = "1.28.0" diff --git a/slack_bolt/workflows/__init__.py b/slack_bolt/workflows/__init__.py index e69de29bb..c0f6d96b7 100644 --- a/slack_bolt/workflows/__init__.py +++ 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 index 8bd0a067a..d7402cb40 100644 --- a/slack_bolt/workflows/step/__init__.py +++ b/slack_bolt/workflows/step/__init__.py @@ -4,3 +4,12 @@ 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 index 334d98821..7fa0ed858 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -1,4 +1,9 @@ -from typing import Callable, Union, Optional, Awaitable +# 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 @@ -9,71 +14,449 @@ ) from slack_bolt.middleware.async_custom_middleware import AsyncCustomMiddleware from slack_bolt.response import BoltResponse -from slack_sdk.web.async_client import AsyncWebClient +from .internals import _is_used_without_argument +from .utilities.async_complete import AsyncComplete from .utilities.async_configure import AsyncConfigure from .utilities.async_fail import AsyncFail -from .utilities.async_complete import AsyncComplete from .utilities.async_update import AsyncUpdate +from ...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: str + 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: str, - edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], - save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], - execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], + 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, app_name, edit, "edit") - self.save = self._build_listener(callback_id, app_name, save, "save") - self.execute = self._build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) + + @classmethod + def builder( + cls, + callback_id: Union[str, Pattern], + 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: str, app_name: str, listener: AsyncListener, name: str, + 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 isinstance(listener, AsyncListener): - return listener - elif isinstance(listener, Callable): + 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=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=listener, - lazy_functions=[], + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: - raise ValueError(f"Invalid `{name}` listener") + raise BoltError(f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})") @classmethod - def _build_matchers(cls, name: str, callback_id: str): + 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)] + return workflow_step_edit(callback_id, asyncio=True, base_logger=base_logger) elif name == "save": - return [workflow_step_save(callback_id, asyncio=True)] + return workflow_step_save(callback_id, asyncio=True, base_logger=base_logger) elif name == "execute": - return [workflow_step_execute(callback_id, asyncio=True)] + return workflow_step_execute(callback_id, asyncio=True, base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name: str, callback_id: str): + def _build_single_middleware( + cls, + name: str, + callback_id: str, + base_logger: Optional[Logger] = None, + ) -> AsyncMiddleware: if name == "edit": - return [_build_edit_listener_middleware(callback_id)] + return _build_edit_listener_middleware(callback_id, base_logger) elif name == "save": - return [_build_save_listener_middleware()] + return _build_save_listener_middleware(base_logger) elif name == "execute": - return [_build_execute_listener_middleware()] + return _build_execute_listener_middleware(base_logger) else: raise ValueError(f"Invalid name {name}") @@ -83,7 +466,10 @@ def _build_middleware(cls, name: str, callback_id: str): ####################### -def _build_edit_listener_middleware(callback_id: str): +def _build_edit_listener_middleware( + callback_id: str, + base_logger: Optional[Logger] = None, +) -> AsyncMiddleware: async def edit_listener_middleware( context: AsyncBoltContext, client: AsyncWebClient, @@ -91,11 +477,17 @@ async def edit_listener_middleware( next: Callable[[], Awaitable[BoltResponse]], ): context["configure"] = AsyncConfigure( - callback_id=callback_id, client=client, body=body, + callback_id=callback_id, + client=client, + body=body, ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=edit_listener_middleware) + return AsyncCustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -103,17 +495,26 @@ async def edit_listener_middleware( ####################### -def _build_save_listener_middleware(): +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,) + context["update"] = AsyncUpdate( + client=client, + body=body, + ) return await next() - return AsyncCustomMiddleware(app_name=__name__, func=save_listener_middleware) + return AsyncCustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -121,15 +522,27 @@ async def save_listener_middleware( ####################### -def _build_execute_listener_middleware(): +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,) + 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) + 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 index cac50d3b0..5801a51e6 100644 --- a/slack_bolt/workflows/step/async_step_middleware.py +++ b/slack_bolt/workflows/step/async_step_middleware.py @@ -1,17 +1,19 @@ +# mypy: ignore-errors from typing import Callable, Optional, Awaitable from slack_bolt.listener.async_listener import AsyncListener -from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_name_for_callable from slack_bolt.workflows.step.async_step import AsyncWorkflowStep -class AsyncWorkflowStepMiddleware(AsyncMiddleware): # type:ignore - def __init__(self, step: AsyncWorkflowStep, listener_runner: AsyncioListenerRunner): +class AsyncWorkflowStepMiddleware(AsyncMiddleware): + """Base middleware for step from app specific ones""" + + def __init__(self, step: AsyncWorkflowStep): self.step = step - self.listener_runner = listener_runner async def async_process( self, @@ -36,18 +38,19 @@ async def async_process( return await next() + @staticmethod async def _run( - self, listener: AsyncListener, req: AsyncBoltRequest, resp: BoltResponse, + listener: AsyncListener, + req: AsyncBoltRequest, + resp: BoltResponse, ) -> Optional[BoltResponse]: - resp, next_was_not_called = await listener.run_async_middleware( - req=req, resp=resp - ) + resp, next_was_not_called = await listener.run_async_middleware(req=req, resp=resp) if next_was_not_called: return None - return await self.listener_runner.run( + return await req.context.listener_runner.run( request=req, response=resp, - listener_name=listener.ack_function.__name__, + listener_name=get_name_for_callable(listener.ack_function), listener=listener, ) diff --git a/slack_bolt/workflows/step/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 index 3d83569ca..4fca25717 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -1,14 +1,20 @@ -from typing import Callable, Union, Optional +# mypy: ignore-errors +from functools import wraps +from logging import Logger +from typing import Callable, Union, Optional, Sequence, Pattern, List -from slack_bolt.context import BoltContext +from slack_bolt.context.context import BoltContext +from slack_bolt.error import BoltError from slack_bolt.listener import Listener, CustomListener +from slack_bolt.listener_matcher import ListenerMatcher, CustomListenerMatcher from slack_bolt.listener_matcher.builtins import ( workflow_step_edit, workflow_step_save, workflow_step_execute, ) -from slack_bolt.middleware import CustomMiddleware +from slack_bolt.middleware import CustomMiddleware, Middleware from slack_bolt.response import BoltResponse +from slack_bolt.workflows.step.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 @@ -16,62 +22,465 @@ 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: str + 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: str, - edit: Union[Callable[..., Optional[BoltResponse]], Listener], - save: Union[Callable[..., Optional[BoltResponse]], Listener], - execute: Union[Callable[..., Optional[BoltResponse]], Listener], + 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, app_name, edit, "edit") - self.save = self._build_listener(callback_id, app_name, save, "save") - self.execute = self._build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=edit, + name="edit", + base_logger=base_logger, + ) + self.save = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=save, + name="save", + base_logger=base_logger, + ) + self.execute = self.build_listener( + callback_id=callback_id, + app_name=app_name, + listener_or_functions=execute, + name="execute", + base_logger=base_logger, + ) @classmethod - def _build_listener(cls, callback_id, app_name, listener, name): - if isinstance(listener, Listener): - return listener - elif isinstance(listener, Callable): + 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=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=listener, - lazy_functions=[], + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, auto_acknowledgement=name == "execute", + base_logger=base_logger, ) else: - raise ValueError(f"Invalid `{name}` listener") + raise BoltError(f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})") @classmethod - def _build_matchers(cls, name, callback_id): + def _build_primary_matcher( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> ListenerMatcher: if name == "edit": - return [workflow_step_edit(callback_id)] + return workflow_step_edit(callback_id, base_logger=base_logger) elif name == "save": - return [workflow_step_save(callback_id)] + return workflow_step_save(callback_id, base_logger=base_logger) elif name == "execute": - return [workflow_step_execute(callback_id)] + return workflow_step_execute(callback_id, base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name, callback_id): + def _build_single_middleware( + cls, + name: str, + callback_id: Union[str, Pattern], + base_logger: Optional[Logger] = None, + ) -> Middleware: if name == "edit": - return [_build_edit_listener_middleware(callback_id)] + return _build_edit_listener_middleware(callback_id, base_logger=base_logger) elif name == "save": - return [_build_save_listener_middleware()] + return _build_save_listener_middleware(base_logger=base_logger) elif name == "execute": - return [_build_execute_listener_middleware()] + return _build_execute_listener_middleware(base_logger=base_logger) else: raise ValueError(f"Invalid name {name}") @@ -81,7 +490,7 @@ def _build_middleware(cls, name, callback_id): ####################### -def _build_edit_listener_middleware(callback_id): +def _build_edit_listener_middleware(callback_id: str, base_logger: Optional[Logger] = None) -> Middleware: def edit_listener_middleware( context: BoltContext, client: WebClient, @@ -89,11 +498,17 @@ def edit_listener_middleware( next: Callable[[], BoltResponse], ): context["configure"] = Configure( - callback_id=callback_id, client=client, body=body, + callback_id=callback_id, + client=client, + body=body, ) return next() - return CustomMiddleware(app_name=__name__, func=edit_listener_middleware) + return CustomMiddleware( + app_name=__name__, + func=edit_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -101,17 +516,24 @@ def edit_listener_middleware( ####################### -def _build_save_listener_middleware(): +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,) + context["update"] = Update( + client=client, + body=body, + ) return next() - return CustomMiddleware(app_name=__name__, func=save_listener_middleware) + return CustomMiddleware( + app_name=__name__, + func=save_listener_middleware, + base_logger=base_logger, + ) ####################### @@ -119,15 +541,27 @@ def save_listener_middleware( ####################### -def _build_execute_listener_middleware(): +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,) + 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) + 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 index 2cbaac772..59af001a7 100644 --- a/slack_bolt/workflows/step/step_middleware.py +++ b/slack_bolt/workflows/step/step_middleware.py @@ -1,21 +1,30 @@ +# mypy: ignore-errors from typing import Callable, Optional from slack_bolt.listener import Listener -from slack_bolt.listener.thread_runner import ThreadListenerRunner from slack_bolt.middleware import Middleware from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse +from slack_bolt.util.utils import get_name_for_callable from slack_bolt.workflows.step.step import WorkflowStep -class WorkflowStepMiddleware(Middleware): # type:ignore - def __init__(self, step: WorkflowStep, listener_runner: ThreadListenerRunner): +class WorkflowStepMiddleware(Middleware): + """Base middleware for step from app specific ones""" + + def __init__(self, step: WorkflowStep): self.step = step - self.listener_runner = listener_runner 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]: if self.step.edit.matches(req=req, resp=resp): resp = self._run(self.step.edit, req, resp) @@ -32,16 +41,19 @@ def process( return next() + @staticmethod def _run( - self, listener: Listener, req: BoltRequest, resp: BoltResponse, + listener: Listener, + req: BoltRequest, + resp: BoltResponse, ) -> Optional[BoltResponse]: resp, next_was_not_called = listener.run_middleware(req=req, resp=resp) if next_was_not_called: return None - return self.listener_runner.run( + return req.context.listener_runner.run( request=req, response=resp, - listener_name=listener.ack_function.__name__, + listener_name=get_name_for_callable(listener.ack_function), listener=listener, ) diff --git a/slack_bolt/workflows/step/utilities/__init__.py b/slack_bolt/workflows/step/utilities/__init__.py index e69de29bb..4cbfed9ce 100644 --- a/slack_bolt/workflows/step/utilities/__init__.py +++ 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 index 6459c3f8a..b73e22aee 100644 --- a/slack_bolt/workflows/step/utilities/async_complete.py +++ b/slack_bolt/workflows/step/utilities/async_complete.py @@ -2,14 +2,35 @@ 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" - ], + 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 index e8011d742..5b9a7f9ae 100644 --- a/slack_bolt/workflows/step/utilities/async_configure.py +++ b/slack_bolt/workflows/step/utilities/async_configure.py @@ -1,17 +1,49 @@ -from typing import List, Optional, Union +from typing import Optional, Union, Sequence from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.models.blocks import Block 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[List[Union[dict, Block]]] = None, + self, + *, + blocks: Optional[Sequence[Union[dict, Block]]] = None, ) -> None: await self.client.views_open( trigger_id=self.body["trigger_id"], diff --git a/slack_bolt/workflows/step/utilities/async_fail.py b/slack_bolt/workflows/step/utilities/async_fail.py index f2c6b1de4..af200bb65 100644 --- a/slack_bolt/workflows/step/utilities/async_fail.py +++ b/slack_bolt/workflows/step/utilities/async_fail.py @@ -2,14 +2,36 @@ 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: + 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" - ], + 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 index a8c9e6f9c..d3409bca3 100644 --- a/slack_bolt/workflows/step/utilities/async_update.py +++ b/slack_bolt/workflows/step/utilities/async_update.py @@ -2,6 +2,45 @@ 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 diff --git a/slack_bolt/workflows/step/utilities/complete.py b/slack_bolt/workflows/step/utilities/complete.py index 4fce1c3d2..e17d2f024 100644 --- a/slack_bolt/workflows/step/utilities/complete.py +++ b/slack_bolt/workflows/step/utilities/complete.py @@ -2,14 +2,35 @@ 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" - ], + 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 index 8f1d29a90..1280be8f7 100644 --- a/slack_bolt/workflows/step/utilities/configure.py +++ b/slack_bolt/workflows/step/utilities/configure.py @@ -1,18 +1,46 @@ -from typing import List, Optional, Union +from typing import Optional, Union, Sequence from slack_sdk.web import WebClient from slack_sdk.models.blocks import Block 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[List[Union[dict, Block]]] = None, **kwargs - ) -> None: + def __call__(self, *, blocks: Optional[Sequence[Union[dict, Block]]] = None, **kwargs) -> None: self.client.views_open( trigger_id=self.body["trigger_id"], view={ diff --git a/slack_bolt/workflows/step/utilities/fail.py b/slack_bolt/workflows/step/utilities/fail.py index 6b82dc272..b96add08b 100644 --- a/slack_bolt/workflows/step/utilities/fail.py +++ b/slack_bolt/workflows/step/utilities/fail.py @@ -2,14 +2,36 @@ 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: + def __call__( + self, + *, + error: dict, + ) -> None: self.client.workflows_stepFailed( - workflow_step_execute_id=self.body["event"]["workflow_step"][ - "workflow_step_execute_id" - ], + 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 index 1c602400e..bfc81d9d3 100644 --- a/slack_bolt/workflows/step/utilities/update.py +++ b/slack_bolt/workflows/step/utilities/update.py @@ -2,6 +2,45 @@ 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 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..72b6434bf --- /dev/null +++ b/tests/adapter_tests/asgi/test_asgi_http.py @@ -0,0 +1,241 @@ +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_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..488a990f1 --- /dev/null +++ b/tests/adapter_tests/asgi/test_asgi_lifespan.py @@ -0,0 +1,74 @@ +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 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_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/test_aws_chalice.py b/tests/adapter_tests/aws/test_aws_chalice.py similarity index 61% rename from tests/adapter_tests/test_aws_chalice.py rename to tests/adapter_tests/aws/test_aws_chalice.py index 9ee64dd49..4a5744333 100644 --- a/tests/adapter_tests/test_aws_chalice.py +++ b/tests/adapter_tests/aws/test_aws_chalice.py @@ -1,12 +1,15 @@ 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 @@ -14,11 +17,14 @@ 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 @@ -40,15 +46,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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), @@ -60,7 +63,10 @@ def test_not_found(self): assert response.status_code == 404 def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) @app.event("app_mention") def event_handler(): @@ -106,10 +112,13 @@ def events() -> Response: headers=self.build_headers(timestamp, body), ) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) @app.shortcut("test-shortcut") def shortcut_handler(ack): @@ -150,10 +159,13 @@ def events() -> Response: headers=self.build_headers(timestamp, body), ) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) @app.command("/hello-world") def command_handler(ack): @@ -194,10 +206,13 @@ def events() -> Response: headers=self.build_headers(timestamp, body), ) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_lazy_listeners(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -248,8 +263,120 @@ def say_it(say): ) response: Response = slack_handler.handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 - assert self.mock_received_requests["/chat.postMessage"] == 1 + 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( @@ -272,4 +399,6 @@ def install() -> Response: response: Dict[str, Any] = LocalGateway(chalice_app, Config()).handle_request( method="GET", path="/slack/install", body="", headers={} ) - assert response["statusCode"] == 302 + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.get("body") diff --git a/tests/adapter_tests/test_aws_lambda.py b/tests/adapter_tests/aws/test_aws_lambda.py similarity index 59% rename from tests/adapter_tests/test_aws_lambda.py rename to tests/adapter_tests/aws/test_aws_lambda.py index c4463c513..c2e888fb7 100644 --- a/tests/adapter_tests/test_aws_lambda.py +++ b/tests/adapter_tests/aws/test_aws_lambda.py @@ -1,10 +1,9 @@ import json from time import time from urllib.parse import quote - -from moto import mock_lambda from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient +from slack_sdk.oauth import OAuthStateStore from slack_bolt.adapter.aws_lambda import SlackRequestHandler from slack_bolt.adapter.aws_lambda.handler import not_found @@ -12,17 +11,25 @@ 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: @@ -30,7 +37,10 @@ class TestAWSLambda: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) context = LambdaContext(function_name="test-function") @@ -44,15 +54,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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)], @@ -68,15 +75,21 @@ def test_first_value(self): assert _first_value({"foo": []}, "foo") is None assert _first_value({}, "foo") is None - @mock_lambda + @mock_aws def test_clear_all_log_handlers(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) handler = SlackRequestHandler(app) handler.clear_all_log_handlers() - @mock_lambda + @mock_aws def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -113,11 +126,25 @@ def event_handler(): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - @mock_lambda + 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,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -149,11 +176,25 @@ def shortcut_handler(ack): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + event = { + "body": body, + "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_lambda + @mock_aws def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -185,11 +226,25 @@ def command_handler(ack): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + event = { + "body": body, + "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_lambda + @mock_aws def test_lazy_listeners(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -227,10 +282,10 @@ def say_it(say): } response = SlackRequestHandler(app).handle(event, self.context) assert response["statusCode"] == 200 - assert self.mock_received_requests["/auth.test"] == 1 - assert self.mock_received_requests["/chat.postMessage"] == 1 + assert_auth_test_count(self, 1) + assert_received_request_count(self, "/chat.postMessage", 1) - @mock_lambda + @mock_aws def test_oauth(self): app = App( client=self.web_client, @@ -250,4 +305,61 @@ def test_oauth(self): "isBase64Encoded": False, } response = SlackRequestHandler(app).handle(event, self.context) - assert response["statusCode"] == 302 + assert response["statusCode"] == 200 + assert response["headers"]["content-type"] == "text/html; charset=utf-8" + assert response.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/test_lambda_s3_oauth_flow.py b/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py similarity index 77% rename from tests/adapter_tests/test_lambda_s3_oauth_flow.py rename to tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py index 4e3644b64..5f79b64ab 100644 --- a/tests/adapter_tests/test_lambda_s3_oauth_flow.py +++ b/tests/adapter_tests/aws/test_lambda_s3_oauth_flow.py @@ -1,9 +1,12 @@ -from moto import mock_s3 - from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow from slack_bolt.oauth.oauth_settings import OAuthSettings from tests.utils import remove_os_env_temporarily, restore_os_env +try: + from moto import mock_aws +except ImportError: + from moto import mock_s3 as mock_aws + class TestLambdaS3OAuthFlow: def setup_method(self): @@ -12,11 +15,13 @@ def setup_method(self): def teardown_method(self): restore_os_env(self.old_os_env) - @mock_s3 + @mock_aws def test_instantiation(self): oauth_flow = LambdaS3OAuthFlow( settings=OAuthSettings( - client_id="111.222", client_secret="xxx", scopes=["chat:write"], + client_id="111.222", + client_secret="xxx", + scopes=["chat:write"], ), installation_bucket_name="dummy-installation", oauth_state_bucket_name="dummy-state", diff --git a/tests/adapter_tests/bottle/__init__.py b/tests/adapter_tests/bottle/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_bottle.py b/tests/adapter_tests/bottle/test_bottle.py similarity index 91% rename from tests/adapter_tests/test_bottle.py rename to tests/adapter_tests/bottle/test_bottle.py index 7cb8dffa0..bd37fcc74 100644 --- a/tests/adapter_tests/test_bottle.py +++ b/tests/adapter_tests/bottle/test_bottle.py @@ -11,6 +11,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -48,8 +49,14 @@ def setup_method(self): self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) - app = App(client=web_client, signing_secret=signing_secret,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + app = App( + client=web_client, + signing_secret=signing_secret, + ) TestBottle.handler = SlackRequestHandler(app) app.event("app_mention")(event_handler) app.shortcut("test-shortcut")(shortcut_handler) @@ -61,7 +68,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): @@ -102,7 +110,7 @@ def test_events(self): response_body = slack_events() assert response.status_code == 200 assert response_body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): input = { @@ -131,7 +139,7 @@ def test_shortcuts(self): response_body = slack_events() assert response.status_code == 200 assert response_body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): input = ( @@ -160,4 +168,4 @@ def test_commands(self): response_body = slack_events() assert response.status_code == 200 assert response_body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/test_bottle_oauth.py b/tests/adapter_tests/bottle/test_bottle_oauth.py similarity index 59% rename from tests/adapter_tests/test_bottle_oauth.py rename to tests/adapter_tests/bottle/test_bottle_oauth.py index b45c71631..52ff7a2ae 100644 --- a/tests/adapter_tests/test_bottle_oauth.py +++ b/tests/adapter_tests/bottle/test_bottle_oauth.py @@ -1,5 +1,3 @@ -import re - from slack_bolt.adapter.bottle import SlackRequestHandler from slack_bolt.app import App from slack_bolt.oauth.oauth_settings import OAuthSettings @@ -8,7 +6,9 @@ app = App( signing_secret=signing_secret, oauth_settings=OAuthSettings( - client_id="111.111", client_secret="xxx", scopes=["chat:write", "commands"], + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], ), ) handler = SlackRequestHandler(app) @@ -26,9 +26,6 @@ class TestBottle: def test_oauth(self): with boddle(method="GET", path="/slack/install"): response_body = install() - assert response_body == "" - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response_body diff --git a/tests/adapter_tests/cherrypy/__init__.py b/tests/adapter_tests/cherrypy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_cherrypy.py b/tests/adapter_tests/cherrypy/test_cherrypy.py similarity index 94% rename from tests/adapter_tests/test_cherrypy.py rename to tests/adapter_tests/cherrypy/test_cherrypy.py index fec885343..f21845911 100644 --- a/tests/adapter_tests/test_cherrypy.py +++ b/tests/adapter_tests/cherrypy/test_cherrypy.py @@ -29,8 +29,14 @@ def setup_server(cls): signing_secret = "secret" valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) - app = App(client=web_client, signing_secret=signing_secret,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + app = App( + client=web_client, + signing_secret=signing_secret, + ) def event_handler(): pass @@ -63,7 +69,8 @@ def teardown_class(cls): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): diff --git a/tests/adapter_tests/test_cherrypy_oauth.py b/tests/adapter_tests/cherrypy/test_cherrypy_oauth.py similarity index 90% rename from tests/adapter_tests/test_cherrypy_oauth.py rename to tests/adapter_tests/cherrypy/test_cherrypy_oauth.py index cbda0b06c..80053331f 100644 --- a/tests/adapter_tests/test_cherrypy_oauth.py +++ b/tests/adapter_tests/cherrypy/test_cherrypy_oauth.py @@ -21,7 +21,10 @@ def setup_server(cls): signing_secret = "secret" valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) app = App( client=web_client, signing_secret=signing_secret, @@ -49,4 +52,4 @@ def teardown_class(cls): def test_oauth(self): cherrypy.request.process_request_body = False self.getPage("/slack/install", method="GET") - self.assertStatus("302 Found") + self.assertStatus("200 OK") diff --git a/tests/adapter_tests/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/test_django.py b/tests/adapter_tests/django/test_django.py similarity index 59% rename from tests/adapter_tests/test_django.py rename to tests/adapter_tests/django/test_django.py index 108dc256d..f31a46411 100644 --- a/tests/adapter_tests/test_django.py +++ b/tests/adapter_tests/django/test_django.py @@ -1,5 +1,4 @@ import json -import os from time import time from urllib.parse import quote @@ -14,6 +13,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -23,9 +23,11 @@ class TestDjango(TestCase): valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - os.environ["DJANGO_SETTINGS_MODULE"] = "tests.adapter_tests.test_django_settings" rf = RequestFactory() def setUp(self): @@ -38,7 +40,8 @@ def tearDown(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -48,7 +51,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -77,17 +83,18 @@ def event_handler(): } timestamp, body = str(int(time())), json.dumps(input) - request = self.rf.post( - "/slack/events", data=body, content_type="application/json" - ) + request = self.rf.post("/slack/events", data=body, content_type="application/json") request.headers = self.build_headers(timestamp, body) response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -120,10 +127,53 @@ def shortcut_handler(ack): response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + 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() @@ -156,7 +206,49 @@ def command_handler(ack): response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + def test_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( @@ -170,4 +262,6 @@ def test_oauth(self): ) request = self.rf.get("/slack/install") response = SlackRequestHandler(app).handle(request) - assert response.status_code == 302 + assert response.status_code == 200 + assert response.get("content-type") == "text/html; charset=utf-8" + assert "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/test_falcon.py b/tests/adapter_tests/falcon/test_falcon.py similarity index 77% rename from tests/adapter_tests/test_falcon.py rename to tests/adapter_tests/falcon/test_falcon.py index 8a00d90fe..d7841a24a 100644 --- a/tests/adapter_tests/test_falcon.py +++ b/tests/adapter_tests/falcon/test_falcon.py @@ -1,9 +1,10 @@ import json -import re from time import time from urllib.parse import quote import falcon + + from falcon import testing from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient @@ -14,16 +15,27 @@ 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,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -35,15 +47,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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), @@ -51,7 +60,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -80,19 +92,24 @@ def event_handler(): } timestamp, body = str(int(time())), json.dumps(input) - api = falcon.API() + api = new_falcon_app() resource = SlackAppResource(app) api.add_route("/slack/events", resource) client = testing.TestClient(api) response = client.simulate_post( - "/slack/events", body=body, headers=self.build_headers(timestamp, body), + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -116,19 +133,24 @@ def shortcut_handler(ack): timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" - api = falcon.API() + api = new_falcon_app() resource = SlackAppResource(app) api.add_route("/slack/events", resource) client = testing.TestClient(api) response = client.simulate_post( - "/slack/events", body=body, headers=self.build_headers(timestamp, body), + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -152,16 +174,18 @@ def command_handler(ack): ) timestamp, body = str(int(time())), input - api = falcon.API() + api = new_falcon_app() resource = SlackAppResource(app) api.add_route("/slack/events", resource) client = testing.TestClient(api) response = client.simulate_post( - "/slack/events", body=body, headers=self.build_headers(timestamp, body), + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( @@ -173,14 +197,11 @@ def test_oauth(self): scopes=["chat:write", "commands"], ), ) - api = falcon.API() + 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 == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + assert response.status_code == 200 + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests/flask/__init__.py b/tests/adapter_tests/flask/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_flask.py b/tests/adapter_tests/flask/test_flask.py similarity index 67% rename from tests/adapter_tests/test_flask.py rename to tests/adapter_tests/flask/test_flask.py index 6776b98f8..2ecac487f 100644 --- a/tests/adapter_tests/test_flask.py +++ b/tests/adapter_tests/flask/test_flask.py @@ -12,6 +12,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -21,7 +22,10 @@ class TestFlask: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -33,15 +37,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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)], @@ -49,7 +50,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -86,13 +90,19 @@ def endpoint(): with flask_app.test_client() as client: rv = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -124,13 +134,19 @@ def endpoint(): with flask_app.test_client() as client: rv = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -162,10 +178,13 @@ def endpoint(): with flask_app.test_client() as client: rv = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), ) assert rv.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert rv.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) def test_oauth(self): app = App( @@ -185,4 +204,35 @@ def endpoint(): with flask_app.test_client() as client: rv = client.get("/slack/install") - assert rv.status_code == 302 + 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/test_pyramid.py b/tests/adapter_tests/pyramid/test_pyramid.py similarity index 85% rename from tests/adapter_tests/test_pyramid.py rename to tests/adapter_tests/pyramid/test_pyramid.py index 5e4180151..a0858a4e7 100644 --- a/tests/adapter_tests/test_pyramid.py +++ b/tests/adapter_tests/pyramid/test_pyramid.py @@ -15,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -24,7 +25,10 @@ class TestPyramid(TestCase): valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setUp(self): self.config = testing.setUp() @@ -38,15 +42,12 @@ def tearDown(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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)], @@ -54,7 +55,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -90,10 +94,13 @@ def event_handler(): request.headers = self.build_headers(timestamp, body) response: Response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -124,10 +131,13 @@ def shortcut_handler(ack): request.headers = self.build_headers(timestamp, body) response: Response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -158,7 +168,7 @@ def command_handler(ack): request.headers = self.build_headers(timestamp, body) response: Response = SlackRequestHandler(app).handle(request) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( @@ -175,4 +185,5 @@ def test_oauth(self): request.path = "/slack/install" request.method = "GET" response: Response = SlackRequestHandler(app).handle(request) - assert response.status_code == 302 + assert response.status_code == 200 + assert "https://slack.com/oauth/v2/authorize?state=" in response.body.decode("utf-8") diff --git a/tests/adapter_tests/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..2ecd52554 --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_interactions_builtin.py @@ -0,0 +1,70 @@ +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} + + @app.shortcut("do-something") + def shortcut_handler(ack): + result["shortcut"] = True + ack() + + @app.command("/hello-socket-mode") + def command_handler(ack): + result["command"] = True + ack() + + handler = SocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + trace_enabled=True, + ) + try: + handler.client.ping_pong_trace_enabled = True + handler.client.wss_uri = "ws://127.0.0.1:3011/link" + + handler.connect() + assert handler.client.is_connected() is True + time.sleep(2) # wait for the message receiver + + handler.client.send_message("foo") + + time.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + finally: + handler.client.close() 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..ccaa89d3e --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_interactions_websocket_client.py @@ -0,0 +1,71 @@ +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} + + @app.shortcut("do-something") + def shortcut_handler(ack): + result["shortcut"] = True + ack() + + @app.command("/hello-socket-mode") + def command_handler(ack): + result["command"] = True + ack() + + handler = SocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + trace_enabled=True, + ) + try: + handler.client.wss_uri = "ws://localhost:3012/link" + handler.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 + finally: + handler.client.close() 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/test_fastapi.py b/tests/adapter_tests/starlette/test_fastapi.py similarity index 64% rename from tests/adapter_tests/test_fastapi.py rename to tests/adapter_tests/starlette/test_fastapi.py index 6bc01c4ca..f91b9897e 100644 --- a/tests/adapter_tests/test_fastapi.py +++ b/tests/adapter_tests/starlette/test_fastapi.py @@ -1,9 +1,8 @@ import json -import re from time import time from urllib.parse import quote -from fastapi import FastAPI +from fastapi import FastAPI, Depends from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient from starlette.requests import Request @@ -15,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -24,7 +24,10 @@ class TestFastAPI: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -36,15 +39,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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), @@ -52,7 +52,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -90,13 +93,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -129,13 +137,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -168,10 +181,12 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( @@ -191,9 +206,56 @@ async def endpoint(req: Request): return await app_handler.handle(req) client = TestClient(api) - response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], + 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/test_starlette.py b/tests/adapter_tests/starlette/test_starlette.py similarity index 80% rename from tests/adapter_tests/test_starlette.py rename to tests/adapter_tests/starlette/test_starlette.py index f662e8840..18066a9d2 100644 --- a/tests/adapter_tests/test_starlette.py +++ b/tests/adapter_tests/starlette/test_starlette.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote @@ -16,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -25,7 +25,10 @@ class TestStarlette: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -37,15 +40,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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), @@ -53,7 +53,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def event_handler(): pass @@ -93,13 +96,18 @@ async def endpoint(req: Request): ) client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def shortcut_handler(ack): ack() @@ -134,13 +142,18 @@ async def endpoint(req: Request): ) client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) def command_handler(ack): ack() @@ -175,10 +188,12 @@ async def endpoint(req: Request): ) client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = App( @@ -200,9 +215,8 @@ async def endpoint(req: Request): routes=[Route("/slack/install", endpoint=endpoint, methods=["GET"])], ) client = TestClient(api) - response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + 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/test_django_settings.py b/tests/adapter_tests/test_django_settings.py deleted file mode 100644 index 7e5f57df3..000000000 --- a/tests/adapter_tests/test_django_settings.py +++ /dev/null @@ -1,4 +0,0 @@ -SECRET_KEY = "XXX" -DATABASES = { - "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "logs/db.sqlite3",} -} diff --git a/tests/adapter_tests/tornado/__init__.py b/tests/adapter_tests/tornado/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/test_tornado.py b/tests/adapter_tests/tornado/test_tornado.py similarity index 87% rename from tests/adapter_tests/test_tornado.py rename to tests/adapter_tests/tornado/test_tornado.py index 468690fb6..e6b77ad91 100644 --- a/tests/adapter_tests/test_tornado.py +++ b/tests/adapter_tests/tornado/test_tornado.py @@ -2,11 +2,10 @@ from time import time from urllib.parse import quote -import tornado from slack_sdk.signature import SignatureVerifier from slack_sdk.web import WebClient from tornado.httpclient import HTTPRequest, HTTPResponse -from tornado.testing import AsyncHTTPTestCase +from tornado.testing import AsyncHTTPTestCase, gen_test from tornado.web import Application from slack_bolt.adapter.tornado import SlackEventsHandler @@ -14,6 +13,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -41,8 +41,14 @@ def setUp(self): self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) - self.app = App(client=web_client, signing_secret=signing_secret,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + self.app = App( + client=web_client, + signing_secret=signing_secret, + ) self.app.event("app_mention")(event_handler) self.app.shortcut("test-shortcut")(shortcut_handler) self.app.command("/hello-world")(command_handler) @@ -59,22 +65,19 @@ def get_app(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" return { "content-type": content_type, "x-slack-signature": self.generate_signature(body, timestamp), "x-slack-request-timestamp": timestamp, } - @tornado.testing.gen_test + @gen_test async def test_events(self): input = { "token": "verification_token", @@ -106,9 +109,9 @@ async def test_events(self): ) response: HTTPResponse = await self.http_client.fetch(request) assert response.code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - @tornado.testing.gen_test + @gen_test async def test_shortcuts(self): input = { "type": "shortcut", @@ -135,9 +138,9 @@ async def test_shortcuts(self): ) response: HTTPResponse = await self.http_client.fetch(request) assert response.code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) - @tornado.testing.gen_test + @gen_test async def test_commands(self): input = ( "token=verification_token" @@ -164,4 +167,4 @@ async def test_commands(self): ) response: HTTPResponse = await self.http_client.fetch(request) assert response.code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) diff --git a/tests/adapter_tests/test_tornado_oauth.py b/tests/adapter_tests/tornado/test_tornado_oauth.py similarity index 63% rename from tests/adapter_tests/test_tornado_oauth.py rename to tests/adapter_tests/tornado/test_tornado_oauth.py index dd892f3bc..a18081572 100644 --- a/tests/adapter_tests/test_tornado_oauth.py +++ b/tests/adapter_tests/tornado/test_tornado_oauth.py @@ -1,6 +1,5 @@ -import tornado -from tornado.httpclient import HTTPRequest, HTTPResponse -from tornado.testing import AsyncHTTPTestCase +from tornado.httpclient import HTTPRequest, HTTPResponse, HTTPClientError +from tornado.testing import AsyncHTTPTestCase, gen_test from tornado.web import Application from slack_bolt.adapter.tornado import SlackOAuthHandler @@ -13,7 +12,9 @@ app = App( signing_secret=signing_secret, oauth_settings=OAuthSettings( - client_id="111.111", client_secret="xxx", scopes=["chat:write", "commands"], + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], ), ) @@ -30,13 +31,11 @@ def tearDown(self): AsyncHTTPTestCase.tearDown(self) restore_os_env(self.old_os_env) - @tornado.testing.gen_test + @gen_test async def test_oauth(self): - request = HTTPRequest( - url=self.get_url("/slack/install"), method="GET", follow_redirects=False - ) + request = HTTPRequest(url=self.get_url("/slack/install"), method="GET", follow_redirects=False) try: response: HTTPResponse = await self.http_client.fetch(request) - assert response.code == 302 - except tornado.httpclient.HTTPClientError as e: - assert e.code == 302 + 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..63ac62627 --- /dev/null +++ b/tests/adapter_tests/wsgi/test_wsgi_http.py @@ -0,0 +1,230 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.wsgi import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.mock_wsgi_server import WsgiTestServer +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWsgiHttp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_raw_headers(self, timestamp: str, body: str = ""): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "host": "123.123.123", + "user-agent": "some slack thing", + "content-length": str(len(body)), + "accept": "application/json,*/*", + "accept-encoding": "gzip,deflate", + "content-type": content_type, + "x-forwarded-for": "123.123.123", + "x-forwarded-proto": "https", + "x-slack-request-timestamp": timestamp, + "x-slack-signature": self.generate_signature(body, timestamp), + } + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + body = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + body = json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + body_data = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + headers = self.build_raw_headers(str(int(time()))) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="GET", headers=headers, path="/slack/install") + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.body + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body_data = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http( + method="POST", + headers=headers, + body=body, + ) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_unsupported_method(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body = "" + headers = self.build_raw_headers(str(int(time())), "") + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="PUT", headers=headers, body=body) + + assert response.status == "404 Not Found" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) diff --git a/tests/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..e8077f10c --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -0,0 +1,72 @@ +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} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + result["shortcut"] = True + await ack() + + @app.command("/hello-socket-mode") + async def command_handler(ack): + result["command"] = True + await ack() + + handler = AsyncSocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + ) + try: + handler.client.wss_uri = "ws://localhost:3021/link" + + await handler.connect_async() + await asyncio.sleep(2) # wait for the message receiver + + await handler.client.send_message("foo") + + await asyncio.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + finally: + await handler.client.close() + 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..84d20b2f9 --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -0,0 +1,72 @@ +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} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + result["shortcut"] = True + await ack() + + @app.command("/hello-socket-mode") + async def command_handler(ack): + result["command"] = True + await ack() + + handler = AsyncSocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + ) + try: + handler.client.wss_uri = "ws://localhost:3022/link" + + await handler.connect_async() + await asyncio.sleep(2) # wait for the message receiver + + await handler.client.send_message("foo") + + await asyncio.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + finally: + await handler.client.close() + 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..6e3901fdf --- /dev/null +++ b/tests/adapter_tests_async/test_async_falcon.py @@ -0,0 +1,205 @@ +import json +from time import time +from urllib.parse import quote + +import falcon +from falcon import testing + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.falcon.async_resource import AsyncSlackAppResource +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +def new_falcon_app(): + return falcon.asgi.App() + + +class TestAsyncFalcon: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def test_events(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + input = { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + timestamp, body = str(int(time())), json.dumps(input) + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_commands(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/events", resource) + + client = testing.TestClient(api) + response = client.simulate_post( + "/slack/events", + body=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + api = new_falcon_app() + resource = AsyncSlackAppResource(app) + api.add_route("/slack/install", resource) + + client = testing.TestClient(api) + response = client.simulate_get("/slack/install") + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("content-length") == "607" + assert "https://slack.com/oauth/v2/authorize?state=" in response.text diff --git a/tests/adapter_tests_async/test_async_fastapi.py b/tests/adapter_tests_async/test_async_fastapi.py index 6c08011a4..e0175d3fa 100644 --- a/tests/adapter_tests_async/test_async_fastapi.py +++ b/tests/adapter_tests_async/test_async_fastapi.py @@ -1,9 +1,8 @@ import json -import re from time import time from urllib.parse import quote -from fastapi import FastAPI +from fastapi import FastAPI, Depends from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient from starlette.requests import Request @@ -15,6 +14,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -24,7 +24,10 @@ class TestFastAPI: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -36,15 +39,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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), @@ -52,7 +52,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def event_handler(): pass @@ -90,13 +93,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def shortcut_handler(ack): await ack() @@ -129,13 +137,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def command_handler(ack): await ack() @@ -168,10 +181,12 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = AsyncApp( @@ -191,9 +206,57 @@ async def endpoint(req: Request): return await app_handler.handle(req) client = TestClient(api) - response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], + 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 index ef0438f1c..9a948e3a6 100644 --- a/tests/adapter_tests_async/test_async_sanic.py +++ b/tests/adapter_tests_async/test_async_sanic.py @@ -1,6 +1,5 @@ import asyncio import json -import re from time import time from urllib.parse import quote @@ -16,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -25,31 +25,33 @@ class TestSanic: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @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: - 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(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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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), @@ -58,7 +60,10 @@ def build_headers(self, timestamp: str, body: str): @pytest.mark.asyncio async def test_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def event_handler(): pass @@ -87,7 +92,7 @@ async def event_handler(): } timestamp, body = str(int(time())), json.dumps(input) - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.post("/slack/events") @@ -95,14 +100,19 @@ async def endpoint(req: Request): return await app_handler.handle(req) _, response = await api.asgi_client.post( - url="/slack/events", data=body, headers=self.build_headers(timestamp, body), + url="/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @pytest.mark.asyncio async def test_shortcuts(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def shortcut_handler(ack): await ack() @@ -126,7 +136,7 @@ async def shortcut_handler(ack): timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.post("/slack/events") @@ -134,14 +144,19 @@ async def endpoint(req: Request): return await app_handler.handle(req) _, response = await api.asgi_client.post( - url="/slack/events", data=body, headers=self.build_headers(timestamp, body), + url="/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @pytest.mark.asyncio async def test_commands(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def command_handler(ack): await ack() @@ -165,7 +180,7 @@ async def command_handler(ack): ) timestamp, body = str(int(time())), input - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.post("/slack/events") @@ -173,10 +188,12 @@ async def endpoint(req: Request): return await app_handler.handle(req) _, response = await api.asgi_client.post( - url="/slack/events", data=body, headers=self.build_headers(timestamp, body), + url="/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) @pytest.mark.asyncio async def test_oauth(self): @@ -189,18 +206,21 @@ async def test_oauth(self): scopes=["chat:write", "commands"], ), ) - api = Sanic(name="awesome-slack-app") + api = Sanic(name=self.unique_sanic_app_name()) app_handler = AsyncSlackRequestHandler(app) @api.get("/slack/install") async def endpoint(req: Request): return await app_handler.handle(req) - _, response = await api.asgi_client.get( - url="/slack/install", allow_redirects=False - ) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + _, 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 index 2214e5c59..849c75168 100644 --- a/tests/adapter_tests_async/test_async_starlette.py +++ b/tests/adapter_tests_async/test_async_starlette.py @@ -1,5 +1,4 @@ import json -import re from time import time from urllib.parse import quote @@ -16,6 +15,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -25,7 +25,10 @@ class TestAsyncStarlette: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -37,15 +40,12 @@ 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): - content_type = ( - "application/json" - if body.startswith("{") - else "application/x-www-form-urlencoded" - ) + 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), @@ -53,7 +53,10 @@ def build_headers(self, timestamp: str, body: str): } def test_events(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def event_handler(): pass @@ -93,13 +96,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_shortcuts(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def shortcut_handler(ack): await ack() @@ -134,13 +142,18 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_commands(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) async def command_handler(ack): await ack() @@ -175,10 +188,12 @@ async def endpoint(req: Request): client = TestClient(api) response = client.post( - "/slack/events", data=body, headers=self.build_headers(timestamp, body), + "/slack/events", + content=body, + headers=self.build_headers(timestamp, body), ) assert response.status_code == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_oauth(self): app = AsyncApp( @@ -201,9 +216,9 @@ async def endpoint(req: Request): ) client = TestClient(api) - response = client.get("/slack/install", allow_redirects=False) - assert response.status_code == 302 - assert re.match( - "https://slack.com/oauth/v2/authorize\\?state=[^&]+&client_id=111.111&scope=chat:write,commands&user_scope=", - response.headers["Location"], - ) + 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/mock_asgi_server.py b/tests/mock_asgi_server.py new file mode 100644 index 000000000..e71a0d3cb --- /dev/null +++ b/tests/mock_asgi_server.py @@ -0,0 +1,122 @@ +from typing import Iterable, Tuple, Union +from slack_bolt.adapter.asgi.base_handler import BaseSlackRequestHandler + +ENCODING = "utf-8" + + +class AsgiTestServerResponse: + def __init__(self): + self.status_code: int = None + self._headers: Iterable[Tuple[bytes, bytes]] = [] + self._body: bytearray = bytearray(b"") + + @property + def body(self): + return self._body.decode(ENCODING) + + @property + def headers(self): + return {header[0].decode(ENCODING): header[1].decode(ENCODING) for header in self._headers} + + +class AsgiTestServerLifespanResponse: + def __init__(self): + self.type: str = None + self.message: str = "" + + +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, + }, + ) + + async def receive(): + return {"type": "http.request", "body": bytes(body, ENCODING), "more_body": False} + + response = AsgiTestServerResponse() + + async def send(event): + if event["type"] == "http.response.start": + response.status_code = event["status"] + response._headers = event["headers"] + elif event["type"] == "http.response.body": + response._body.extend(event["body"]) + else: + raise TypeError(f"Sent type {event['type']} in response {event} is not valid") + + await self.asgi_app(scope, receive, send) + return response + + 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", + }, + ) + + async def receive(): + return {"type": f"lifespan.{event}"} + + response = AsgiTestServerLifespanResponse() + + async def send(event: dict): + response.type = event["type"] + response.message = event.get("message", "") + + await self.asgi_app(scope, receive, send) + return response + + async def websocket(self) -> None: + """This is not implemented""" + scope = dict( + self.server_scope, + **{ + "type": "websocket", + }, + ) + + async def receive(): + return {} + + async def send(event: dict): + print(event) + + await self.asgi_app(scope, receive, send) diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py deleted file mode 100644 index 41c1d09c5..000000000 --- a/tests/mock_web_api_server.py +++ /dev/null @@ -1,195 +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 is_valid_user_token(self): - return "Authorization" in self.headers and str( - self.headers["Authorization"] - ).startswith("Bearer xoxp-") - - def set_common_headers(self): - self.send_header("content-type", "application/json;charset=utf-8") - self.send_header("connection", "close") - self.end_headers() - - invalid_auth = { - "ok": False, - "error": "invalid_auth", - } - - oauth_v2_access_response = """ -{ - "ok": true, - "access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", - "token_type": "bot", - "scope": "chat:write,commands", - "bot_user_id": "U0KRQLJ9H", - "app_id": "A0KRD7HC3", - "team": { - "name": "Slack Softball Team", - "id": "T9TK3CUKW" - }, - "enterprise": { - "name": "slack-sports", - "id": "E12345678" - }, - "authed_user": { - "id": "U1234", - "scope": "chat:write", - "access_token": "xoxp-1234", - "token_type": "user" - } -} - """ - bot_auth_test_response = """ -{ - "ok": true, - "url": "https://subarachnoid.slack.com/", - "team": "Subarachnoid Workspace", - "user": "bot", - "team_id": "T0G9PQBBK", - "user_id": "W23456789", - "bot_id": "BZYBOTHED" -} -""" - - user_auth_test_response = """ -{ - "ok": true, - "url": "https://subarachnoid.slack.com/", - "team": "Subarachnoid Workspace", - "user": "some-user", - "team_id": "T0G9PQBBK", - "user_id": "W99999" -} -""" - - def _handle(self): - self.received_requests[self.path] = self.received_requests.get(self.path, 0) + 1 - try: - body = {"ok": True} - if self.path == "/oauth.v2.access": - self.send_response(200) - self.set_common_headers() - self.wfile.write(self.oauth_v2_access_response.encode("utf-8")) - return - - if self.is_valid_user_token(): - if self.path == "/auth.test": - self.send_response(200) - self.set_common_headers() - self.wfile.write(self.user_auth_test_response.encode("utf-8")) - return - - if self.is_valid_token(): - parsed_path = urlparse(self.path) - - if self.path == "/auth.test": - self.send_response(200) - self.set_common_headers() - self.wfile.write(self.bot_auth_test_response.encode("utf-8")) - return - - len_header = self.headers.get("Content-Length") or 0 - content_len = int(len_header) - post_body = self.rfile.read(content_len) - request_body = None - if post_body: - try: - post_body = post_body.decode("utf-8") - if post_body.startswith("{"): - request_body = json.loads(post_body) - else: - request_body = { - k: v[0] for k, v in parse_qs(post_body).items() - } - except UnicodeDecodeError: - pass - else: - if parsed_path and parsed_path.query: - request_body = { - k: v[0] for k, v in parse_qs(parsed_path.query).items() - } - - 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..a389a898e --- /dev/null +++ b/tests/mock_wsgi_server.py @@ -0,0 +1,116 @@ +from typing import Dict, Iterable, Optional, Tuple + +from slack_bolt.adapter.wsgi import SlackRequestHandler + +ENCODING = "utf-8" + + +class WsgiTestServerResponse: + def __init__(self): + self.status: Optional[str] = None + self._headers: Iterable[Tuple[str, str]] = [] + self._body: Iterable[bytes] = [] + + @property + def headers(self) -> Dict[str, str]: + return {header[0]: header[1] for header in self._headers} + + @property + def body(self, length: int = 0) -> str: + return "".join([chunk.decode(ENCODING) for chunk in self._body[length:]]) + + +class MockReadable: + def __init__(self, body: str): + self.body = body + self._body = bytes(body, ENCODING) + + def get_content_length(self) -> int: + return len(self._body) + + def read(self, size: int) -> bytes: + if size < 0: + raise ValueError("Size must be positive.") + if size == 0: + return b"" + # The body can only be read once + _body = self._body[:size] + self._body = b"" + return _body + + +class WsgiTestServer: + def __init__( + self, + wsgi_app: SlackRequestHandler, + root_path: str = "", + version: Tuple[int, int] = (1, 0), + multithread: bool = False, + multiprocess: bool = False, + run_once: bool = False, + input_terminated: bool = True, + server_software: bool = "mock/0.0.0", + url_scheme: str = "https", + remote_addr: str = "127.0.0.1", + remote_port: str = "63263", + ): + self.root_path = root_path + self.wsgi_app = wsgi_app + self.environ = { + "wsgi.version": version, + "wsgi.multithread": multithread, + "wsgi.multiprocess": multiprocess, + "wsgi.run_once": run_once, + "wsgi.input_terminated": input_terminated, + "SERVER_SOFTWARE": server_software, + "wsgi.url_scheme": url_scheme, + "REMOTE_ADDR": remote_addr, + "REMOTE_PORT": remote_port, + } + + def http( + self, + method: str, + headers: Dict[str, str], + body: Optional[str] = None, + path: str = "/slack/events", + query_string: str = "", + server_protocol: str = "HTTP/1.1", + server_name: str = "0.0.0.0", + server_port: str = "3000", + script_name: str = "", + ) -> WsgiTestServerResponse: + environ = dict( + self.environ, + **{ + "REQUEST_METHOD": method, + "PATH_INFO": f"{self.root_path}{path}", + "QUERY_STRING": query_string, + "RAW_URI": f"{self.root_path}{path}?{query_string}", + "SERVER_PROTOCOL": server_protocol, + "SERVER_NAME": server_name, + "SERVER_PORT": server_port, + "SCRIPT_NAME": script_name, + }, + ) + for key, value in headers.items(): + header_key = key.upper().replace("-", "_") + if header_key in {"CONTENT_LENGTH", "CONTENT_TYPE"}: + environ[header_key] = value + else: + environ[f"HTTP_{header_key}"] = value + + if body is not None: + environ["wsgi.input"] = MockReadable(body) + if "CONTENT_LENGTH" not in environ: + environ["CONTENT_LENGTH"] = str(environ["wsgi.input"].get_content_length()) + + response = WsgiTestServerResponse() + + def start_response(status, headers): + response.status = status + response._headers = headers + + response._body = self.wsgi_app(environ=environ, start_response=start_response) + + return response diff --git a/tests/scenario_tests/test_app.py b/tests/scenario_tests/test_app.py index 4bfef046f..9fe6f423f 100644 --- a/tests/scenario_tests/test_app.py +++ b/tests/scenario_tests/test_app.py @@ -1,16 +1,19 @@ +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 ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.utils import remove_os_env_temporarily, restore_os_env @@ -18,7 +21,10 @@ class TestApp: signing_secret = "secret" valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -28,11 +34,15 @@ 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() @@ -42,13 +52,29 @@ def test_listener_registration_error(self): with pytest.raises(BoltError): app.action({"type": "invalid_type", "action_id": "a"})(self.simple_listener) + def test_listener_executor(self): + class TestExecutor(Executor): + """A executor that does nothing for testing""" + + pass + + executor = TestExecutor() + app = App( + signing_secret="valid", + client=self.web_client, + listener_executor=executor, + ) + + assert app.listener_runner.listener_executor == executor + assert app.listener_runner.lazy_listener_runner.executor == executor + # -------------------------- # single team auth # -------------------------- def test_valid_single_auth(self): app = App(signing_secret="valid", client=self.web_client) - assert app != None + assert app is not None def test_token_absence(self): with pytest.raises(BoltError): @@ -56,6 +82,20 @@ def test_token_absence(self): with pytest.raises(BoltError): App(signing_secret="valid", token="") + def test_token_verification_enabled_False(self): + App( + signing_secret="valid", + client=self.web_client, + token_verification_enabled=False, + ) + App( + signing_secret="valid", + token="xoxb-invalid", + token_verification_enabled=False, + ) + + assert self.received_requests.get("/auth.test") is None + # -------------------------- # multi teams auth # -------------------------- @@ -65,7 +105,7 @@ def test_valid_multi_auth(self): signing_secret="valid", oauth_settings=OAuthSettings(client_id="111.222", client_secret="valid"), ) - assert app != None + assert app is not None def test_valid_multi_auth_oauth_flow(self): oauth_flow = OAuthFlow( @@ -77,7 +117,7 @@ def test_valid_multi_auth_oauth_flow(self): ) ) app = App(signing_secret="valid", oauth_flow=oauth_flow) - assert app != None + assert app is not None def test_valid_multi_auth_client_id_absence(self): with pytest.raises(BoltError): @@ -92,3 +132,214 @@ def test_valid_multi_auth_secret_absence(self): signing_secret="valid", oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), ) + + def test_authorize_conflicts(self): + oauth_settings = OAuthSettings( + client_id="111.222", + client_secret="valid", + installation_store=FileInstallationStore(), + state_store=FileOAuthStateStore(expiration_seconds=120), + ) + + # no error with this + App(signing_secret="valid", oauth_settings=oauth_settings) + + def authorize() -> AuthorizeResult: + return AuthorizeResult(enterprise_id="E111", team_id="T111") + + with pytest.raises(BoltError): + App( + signing_secret="valid", + authorize=authorize, + oauth_settings=oauth_settings, + ) + + oauth_flow = OAuthFlow(settings=oauth_settings) + # no error with this + App(signing_secret="valid", oauth_flow=oauth_flow) + + with pytest.raises(BoltError): + App(signing_secret="valid", authorize=authorize, oauth_flow=oauth_flow) + + 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 index 2cf299eb8..645725add 100644 --- a/tests/scenario_tests/test_app_decorators.py +++ b/tests/scenario_tests/test_app_decorators.py @@ -43,6 +43,13 @@ def handle_message_events(body: dict): 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 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 3596dbcbd..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,42 +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 = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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( @@ -87,54 +102,69 @@ 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_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) app.action("unknown")(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(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({"callback_id": "unknown", "type": "interactive_message",})( - simple_listener - ) + app.action( + { + "callback_id": "unknown", + "type": "interactive_message", + } + )(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + 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"] == 1 + assert_auth_test_count(self, 1) -# https://api.slack.com/legacy/interactive-messages +# https://docs.slack.dev/legacy/legacy-messaging/legacy-making-messages-interactive/ body = { "type": "interactive_message", "actions": [ diff --git a/tests/scenario_tests/test_authorize.py b/tests/scenario_tests/test_authorize.py index 86bebef7d..13c248693 100644 --- a/tests/scenario_tests/test_authorize.py +++ b/tests/scenario_tests/test_authorize.py @@ -5,13 +5,11 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltResponse from slack_bolt.app import App from slack_bolt.authorization import AuthorizeResult -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +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" @@ -24,7 +22,8 @@ def authorize(enterprise_id, team_id, user_id, client: WebClient): assert user_id == "W99999" auth_test = client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, bot_token=valid_token, + auth_test_response=auth_test, + bot_token=valid_token, ) @@ -34,7 +33,8 @@ def user_authorize(enterprise_id, team_id, user_id, client: WebClient): assert user_id == "W99999" auth_test = client.auth_test(token=valid_user_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, user_token=valid_user_token, + auth_test_response=auth_test, + user_token=valid_user_token, ) @@ -49,7 +49,10 @@ class TestAuthorize: signing_secret = "secret" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -61,7 +64,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): @@ -71,11 +75,38 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> BoltRequest: + def build_block_actions_request(self) -> BoltRequest: + timestamp = str(int(time())) + return BoltRequest(body=block_actions_raw_body, headers=self.build_headers(timestamp, block_actions_raw_body)) + + def build_message_changed_event_request(self) -> BoltRequest: timestamp = str(int(time())) - return BoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) + 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( @@ -85,11 +116,11 @@ def test_success(self): ) app.action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): app = App( @@ -99,11 +130,11 @@ def test_failure(self): ) app.action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 - assert response.body == ":x: Please install this app into the workspace :bow:" - assert self.mock_received_requests.get("/auth.test") == None + assert response.body == "" + assert_auth_test_count(self, 0) def test_bot_context_attributes(self): app = App( @@ -113,11 +144,11 @@ def test_bot_context_attributes(self): ) app.action("a")(assert_bot_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_user_context_attributes(self): app = App( @@ -127,14 +158,40 @@ def test_user_context_attributes(self): ) app.action("a")(assert_user_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + def test_before_authorize(self): + def skip_message_changed_events(body: dict, payload: dict, next_): + if is_event(body) and payload.get("type") == "message" and payload.get("subtype") == "message_changed": + return BoltResponse(status=200, body="as expected") + next_() + + app = App( + client=self.web_client, + before_authorize=skip_message_changed_events, + authorize=user_authorize, + signing_secret=self.signing_secret, + ) + app.action("a")(assert_user_context_attributes) + + request = self.build_block_actions_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "" + assert_auth_test_count(self, 1) + + request = self.build_message_changed_event_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == "as expected" + assert_auth_test_count(self, 1) # should be skipped -body = { +block_actions_body = { "type": "block_actions", "user": { "id": "W99999", @@ -171,7 +228,7 @@ def test_user_context_attributes(self): ], } -raw_body = f"payload={quote(json.dumps(body))}" +block_actions_raw_body = f"payload={quote(json.dumps(block_actions_body))}" def simple_listener(ack, body, payload, action): diff --git a/tests/scenario_tests/test_block_actions.py b/tests/scenario_tests/test_block_actions.py index bfe851c6f..281cc606c 100644 --- a/tests/scenario_tests/test_block_actions.py +++ b/tests/scenario_tests/test_block_actions.py @@ -10,6 +10,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -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,7 +89,7 @@ def test_process_before_response(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_default_type(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -89,7 +98,7 @@ def test_default_type(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_default_type_no_block_id(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -98,7 +107,16 @@ def test_default_type_no_block_id(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + def test_default_type_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) @@ -107,31 +125,37 @@ def test_default_type_and_unmatched_block_id(self): request = self.build_valid_request() response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure(self): - app = App(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"] == 1 + 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"] == 1 + assert_auth_test_count(self, 1) body = { 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 341039377..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,43 +129,80 @@ 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"] == 1 + 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"] == 1 + 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"] == 1 + assert_auth_test_count(self, 1) + + def test_empty_options(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_options) + + request = self.build_valid_multi_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == """{"options": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_empty_option_groups(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_option_groups) + + request = self.build_valid_multi_request() + response = app.dispatch(request) + assert response.status == 200 + assert response.body == """{"option_groups": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) body = { @@ -238,9 +285,7 @@ def test_failure_multi(self): 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 = { @@ -273,3 +318,15 @@ def show_multi_options(ack, body, payload, options): assert body == options assert payload == options ack(multi_response) + + +def show_empty_options(ack, body, payload, options): + assert body == options + assert payload == options + ack(options=[]) + + +def show_empty_option_groups(ack, body, payload, options): + assert body == options + assert payload == options + ack(option_groups=[]) diff --git a/tests/scenario_tests/test_dialogs.py b/tests/scenario_tests/test_dialogs.py index 1d6b20c8f..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): @@ -50,7 +55,10 @@ def test_mock_server_is_running(self): assert resp != None def test_success_without_type(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("dialog-callback-id")(handle_suggestion) app.action("dialog-callback-id")(handle_submission_cancellation) @@ -59,53 +67,53 @@ def test_success_without_type(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) - 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 = 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"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_2(self): - app = App(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) @@ -115,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"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response(self): app = App( @@ -135,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"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response_2(self): app = App( @@ -179,133 +181,154 @@ def test_process_before_response_2(self): assert response.status == 200 assert response.body == json.dumps(options_response) assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(submission_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(cancellation_raw_body) response = app.dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_suggestion_failure_without_type(self): - app = App(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("dialog-callback-iddddd")(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_suggestion_failure(self): - app = App(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"] == 1 + 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) + app.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"})(handle_suggestion) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_submission_failure_without_type(self): - app = App(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("dialog-callback-iddddd")(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_submission_failure(self): - app = App(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"] == 1 + 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 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({"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"})(handle_submission) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_cancellation_failure_without_type(self): - app = App(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("dialog-callback-iddddd")(handle_cancellation) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_cancellation_failure(self): - app = App(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"] == 1 + 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"] == 1 + assert_auth_test_count(self, 1) suggestion_body = { @@ -381,7 +404,10 @@ def handle_submission(ack): "value": "UXD-342", }, {"label": "[FE-459] Remove the marquee tag", "value": "FE-459"}, - {"label": "[FE-238] Too many shades of gray in master CSS", "value": "FE-238",}, + { + "label": "[FE-238] Too many shades of gray in master CSS", + "value": "FE-238", + }, ] } diff --git a/tests/scenario_tests/test_error_handler.py b/tests/scenario_tests/test_error_handler.py index 131454633..856b85ddf 100644 --- a/tests/scenario_tests/test_error_handler.py +++ b/tests/scenario_tests/test_error_handler.py @@ -5,8 +5,9 @@ from slack_sdk import WebClient from slack_sdk.signature import SignatureVerifier -from slack_bolt import BoltRequest +from slack_bolt import BoltRequest, BoltResponse from slack_bolt.app import App +from slack_bolt.error import BoltUnhandledRequestError from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, @@ -19,7 +20,10 @@ class TestErrorHandler: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -35,7 +39,8 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -48,11 +53,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -68,9 +77,7 @@ def build_valid_request(self) -> BoltRequest: } raw_body = f"payload={quote(json.dumps(body))}" 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)) # ---------------- # tests @@ -80,7 +87,10 @@ def test_default(self): def failing_listener(): raise Exception("Something wrong!") - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(failing_listener) request = self.build_valid_request() @@ -95,7 +105,10 @@ def error_handler(logger, payload, response): def failing_listener(): raise Exception("Something wrong!") - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.error(error_handler) app.action("a")(failing_listener) @@ -139,3 +152,117 @@ def failing_listener(): response = app.dispatch(request) assert response.status == 500 assert response.headers["x-test-result"] == ["1"] + + def test_unhandled_errors(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_process_before_response(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_no_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + + @app.middleware + def broken_middleware(): + pass + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + def test_unhandled_errors_process_before_response_no_next(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + + @app.middleware + def broken_middleware(): + pass + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = app.dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + 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 366490571..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): @@ -75,14 +84,11 @@ def handle_app_mention(body, say, 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) - ) + 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) @@ -98,12 +104,10 @@ def handle_app_mention(body, logger, 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) - ) + 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", @@ -135,14 +139,11 @@ def handle_app_mention(body, say, 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) - ) + 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_stable_auto_ack(self): app = App(client=self.web_client, signing_secret=self.signing_secret) @@ -156,8 +157,433 @@ def handle_app_mention(): str(int(time())), json.dumps(self.valid_reaction_added_body), ) - request: BoltRequest = BoltRequest( - body=body, headers=self.build_headers(timestamp, 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..a1c3f1343 --- /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": "D111", + "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": "D111", + "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..db7d07ea8 --- /dev/null +++ b/tests/scenario_tests/test_events_ignore_self.py @@ -0,0 +1,154 @@ +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", + }, + "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..5a4fc2685 --- /dev/null +++ b/tests/scenario_tests/test_function.py @@ -0,0 +1,333 @@ +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 + +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: Mock, sleep_mock: Mock): + monkeypatch.setattr(time, "time", time_mock) + monkeypatch.setattr(time, "sleep", sleep_mock) + + def test_valid_callback_id_success(self): + app = App( + client=self.web_client, + 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) + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=Mock(), + ) + response = app.dispatch(request) + + assert response.status == 404 + assert_auth_test_count(self, 1) + assert f"WARNING {just_no_ack.__name__} didn't call ack()" in caplog.text + + def test_function_handler_timeout(self, monkeypatch): + 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) + + sleep_mock = Mock() + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=sleep_mock, + ) + + response = app.dispatch(request) + + assert response.status == 404 + assert_auth_test_count(self, 1) + assert ( + sleep_mock.call_count == timeout + ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + + def test_warning_when_timeout_improperly_set(self, caplog): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(just_no_ack) + assert "WARNING" not in caplog.text + + timeout_argument_name = "ack_timeout" + kwargs = {timeout_argument_name: 5} + + callback_id = "reverse1" + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f'WARNING On @app.function("{callback_id}"), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused' + in caplog.text + ) + + callback_id = re.compile(r"hello \w+") + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f"WARNING On @app.function({callback_id}), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused" + in caplog.text + ) + + +function_body = { + "token": "verification_token", + "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 index 0a81b3488..3b2aefbea 100644 --- a/tests/scenario_tests/test_lazy.py +++ b/tests/scenario_tests/test_lazy.py @@ -7,10 +7,7 @@ from slack_bolt import BoltRequest from slack_bolt.app import App -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import assert_received_request_count, cleanup_mock_web_api_server, setup_mock_web_api_server from tests.utils import remove_os_env_temporarily, restore_os_env @@ -19,7 +16,10 @@ class TestErrorHandler: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -35,7 +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): @@ -48,11 +49,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> BoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -68,9 +73,7 @@ def build_valid_request(self) -> BoltRequest: } 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) - ) + return BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) # ---------------- # tests @@ -88,13 +91,112 @@ def async2(say): time.sleep(0.5) say(text="lazy function 2") - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")( + ack=just_ack, + lazy=[async1, async2], + ) + + 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=[async1, async2], + ack=just_ack, + lazy=[LazyClass(), async2], ) request = self.build_valid_request() response = app.dispatch(request) assert response.status == 200 - time.sleep(1) # wait a bit - assert self.mock_received_requests["/chat.postMessage"] == 2 + 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 1acfe56b7..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,77 +48,206 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_request(self) -> BoltRequest: + def build_request_from_body(self, message_body: dict) -> BoltRequest: timestamp, body = str(int(time.time())), json.dumps(message_body) return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def build_request(self) -> BoltRequest: + return self.build_request_from_body(message_body) + def build_request2(self) -> BoltRequest: - timestamp, body = str(int(time.time())), json.dumps(message_body2) - return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + return self.build_request_from_body(message_body2) + + def 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 = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) request = self.build_request2() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + 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 + app = App( + client=self.web_client, + signing_secret=self.signing_secret, ) + app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))(verify_matches) request = self.build_request2() response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - time.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + 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_body = { @@ -178,9 +314,40 @@ def whats_up(body, payload, message, say): } +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 index 517c1b1ce..6553445df 100644 --- a/tests/scenario_tests/test_middleware.py +++ b/tests/scenario_tests/test_middleware.py @@ -1,14 +1,23 @@ import json -from time import time +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 @@ -18,7 +27,10 @@ class TestMiddleware: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() @@ -50,7 +62,8 @@ def build_request(self) -> BoltRequest: "content-type": ["application/json"], "x-slack-signature": [ self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) ], "x-slack-request-timestamp": [timestamp], @@ -58,23 +71,131 @@ def build_request(self) -> BoltRequest: ) def test_no_next_call(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(no_next) app.shortcut("test-shortcut")(just_ack) response = app.dispatch(self.build_request()) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_next_call(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(just_next) app.shortcut("test-shortcut")(just_ack) response = app.dispatch(self.build_request()) assert response.status == 200 assert response.body == "acknowledged!" - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) + + def test_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): @@ -87,3 +208,46 @@ def no_next(): 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 b646d77a3..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): @@ -51,71 +56,84 @@ def test_mock_server_is_running(self): # NOTE: This is a compatible behavior with Bolt for JS def test_success_both_global_and_message(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_global(self): - app = App(client=self.web_client, signing_secret=self.signing_secret,) + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_success_global_2(self): - app = App(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"] == 1 + 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"] == 1 + 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"] == 1 + assert_auth_test_count(self, 1) def test_process_before_response_global(self): app = App( @@ -128,36 +146,42 @@ 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"] == 1 + 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"] == 1 + assert_auth_test_count(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) global_shortcut_body = { diff --git a/tests/scenario_tests/test_slash_command.py b/tests/scenario_tests/test_slash_command.py index acfac442b..1db13ecca 100644 --- a/tests/scenario_tests/test_slash_command.py +++ b/tests/scenario_tests/test_slash_command.py @@ -9,6 +9,7 @@ from tests.mock_web_api_server import ( setup_mock_web_api_server, cleanup_mock_web_api_server, + assert_auth_test_count, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -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): @@ -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,19 +76,22 @@ 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"] == 1 + assert_auth_test_count(self, 1) slash_command_body = ( 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 55c34ab4c..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,43 +89,52 @@ 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"] == 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({"type": "view_closed", "callback_id": "view-idddd"})(simple_listener) response = app.dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_failure_2(self): - app = App(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"] == 1 + assert_auth_test_count(self, 1) body = { @@ -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", diff --git a/tests/scenario_tests/test_view_submission.py b/tests/scenario_tests/test_view_submission.py index c0ecbfa2e..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,90 +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"] == 1 + 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"] == 1 + assert_auth_test_count(self, 1) + def test_response_urls(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) -body = { - "type": "view_submission", - "team": { - "id": "T111", - "domain": "workspace-domain", - "enterprise_id": "E111", - "enterprise_name": "Sandbox Org", - }, - "user": { - "id": "W111", - "username": "primary-owner", - "name": "primary-owner", - "team_id": "T111", - }, - "api_app_id": "A111", - "token": "verification_token", - "trigger_id": "111.222.valid", - "view": { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "input", - "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, - "optional": False, - "element": {"type": "plain_text_input", "action_id": "maBWU"}, - } - ], - "private_metadata": "This is for you!", - "callback_id": "view-id", - "state": { - "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} - }, - "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, - "clear_on_close": False, - "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "previous_view_id": None, - "root_view_id": "V111", - "app_id": "A111", - "external_id": "", - "app_installed_team_id": "T111", - "bot_id": "B111", - }, - "response_urls": [], -} + @app.view("view-id") + def check(ack, respond): + respond("Hi") + ack() -raw_body = f"payload={quote(json.dumps(body))}" + 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, payload, view): - assert body["trigger_id"] == "111.222.valid" - assert body["view"] == payload - assert payload == view - assert view["private_metadata"] == "This is for you!" - ack() + request = self.build_valid_request(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 index 82a67a356..4c644f027 100644 --- a/tests/scenario_tests/test_workflow_steps.py +++ b/tests/scenario_tests/test_workflow_steps.py @@ -1,16 +1,18 @@ import json -import time as time_module +import logging from time import time from urllib.parse import quote from slack_sdk.signature import SignatureVerifier -from slack_sdk.web import WebClient, SlackResponse +from slack_sdk.web import SlackResponse, WebClient -from slack_bolt import App, BoltRequest, Ack -from slack_bolt.workflows.step import Complete, Fail, Update, Configure +from slack_bolt import Ack, App, BoltRequest +from slack_bolt.workflows.step import Complete, Configure, Fail, Update from tests.mock_web_api_server import ( - setup_mock_web_api_server, + 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 @@ -25,8 +27,6 @@ class TestWorkflowSteps: def setup_method(self): self.old_os_env = remove_os_env_temporarily() setup_mock_web_api_server(self) - self.app = App(client=self.web_client, signing_secret=self.signing_secret) - self.app.step(callback_id="copy_review", edit=edit, save=save, execute=execute) def teardown_method(self): cleanup_mock_web_api_server(self) @@ -34,10 +34,32 @@ def teardown_method(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, + ) + + def build_app(self, callback_id: str): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.step(callback_id=callback_id, edit=edit, save=save, execute=execute) + return app + + def build_process_before_response_app(self, callback_id: str): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.step( + callback_id=callback_id, + edit=[edit_ack, edit_lazy], + save=[save_ack, save_lazy], + execute=[execute_ack, execute_lazy], ) + return app def test_edit(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" headers = { "content-type": ["application/x-www-form-urlencoded"], @@ -45,18 +67,35 @@ def test_edit(self): "x-slack-request-timestamp": [timestamp], } request: BoltRequest = BoltRequest(body=body, headers=headers) - response = self.app.dispatch(request) + response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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) + 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"], @@ -64,18 +103,35 @@ def test_save(self): "x-slack-request-timestamp": [timestamp], } request: BoltRequest = BoltRequest(body=body, headers=headers) - response = self.app.dispatch(request) + response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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) + 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"], @@ -83,19 +139,93 @@ def test_execute(self): "x-slack-request-timestamp": [timestamp], } request: BoltRequest = BoltRequest(body=body, headers=headers) - response = self.app.dispatch(request) + response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - time_module.sleep(0.5) - assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + 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) + 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", @@ -294,7 +424,10 @@ def edit(ack: Ack, step, configure: Configure): "element": { "type": "plain_text_input", "action_id": "task_name", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task name"}, }, @@ -317,7 +450,10 @@ def edit(ack: Ack, step, configure: Configure): "element": { "type": "plain_text_input", "action_id": "task_author", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task author"}, }, @@ -335,18 +471,28 @@ def save(ack: Ack, step: dict, view: dict, update: Update): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], + "value": state_values["task_description_input"]["task_description"]["value"], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name",}, - {"name": "taskDescription", "type": "text", "label": "Task Description",}, - {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, ], ) ack() @@ -366,9 +512,7 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): } ) - user: SlackResponse = client.users_lookupByEmail( - email=step["inputs"]["taskAuthorEmail"]["value"] - ) + user: SlackResponse = client.users_lookupByEmail(email=step["inputs"]["taskAuthorEmail"]["value"]) user_id = user["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], @@ -398,3 +542,37 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): ) except Exception as err: fail(error={"message": f"Something wrong! {err}"}) + + +def edit_ack(ack: Ack): + ack() + + +def edit_lazy(step, configure: Configure): + assert step is not None + configure(blocks=[]) + + +def save_ack(ack: Ack): + ack() + + +def save_lazy(step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + update( + inputs={}, + outputs=[], + ) + + +def execute_ack(): + pass + + +def execute_lazy(step: dict, complete: Complete, fail: Fail): + assert step is not None + try: + complete(outputs={}) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests/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/test_app.py b/tests/scenario_tests_async/test_app.py index a22c95a3e..6f3fb34f8 100644 --- a/tests/scenario_tests_async/test_app.py +++ b/tests/scenario_tests_async/test_app.py @@ -1,29 +1,48 @@ +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.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, + 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 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() @@ -67,9 +86,7 @@ def test_token_absence(self): def test_valid_multi_auth(self): app = AsyncApp( signing_secret="valid", - oauth_settings=AsyncOAuthSettings( - client_id="111.222", client_secret="valid" - ), + oauth_settings=AsyncOAuthSettings(client_id="111.222", client_secret="valid"), ) assert app != None @@ -89,12 +106,199 @@ def test_valid_multi_auth_client_id_absence(self): with pytest.raises(BoltError): AsyncApp( signing_secret="valid", - oauth_settings=OAuthSettings(client_id=None, client_secret="valid"), + oauth_settings=AsyncOAuthSettings(client_id=None, client_secret="valid"), ) def test_valid_multi_auth_secret_absence(self): with pytest.raises(BoltError): AsyncApp( signing_secret="valid", - oauth_settings=OAuthSettings(client_id="111.222", client_secret=None), + oauth_settings=AsyncOAuthSettings(client_id="111.222", client_secret=None), + ) + + def test_authorize_conflicts(self): + oauth_settings = 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 index 15d02c09b..c54306bba 100644 --- a/tests/scenario_tests_async/test_app_decorators.py +++ b/tests/scenario_tests_async/test_app_decorators.py @@ -7,8 +7,8 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.context.ack.async_ack import AsyncAck from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -28,7 +28,7 @@ class TestAppDecorators: async def test_decorators(self): try: self.old_os_env = remove_os_env_temporarily() - setup_mock_web_api_server(self) + setup_mock_web_api_server_async(self) app = AsyncApp(signing_secret=self.signing_secret, client=self.web_client) ack = NoopAsyncAck() @@ -106,5 +106,5 @@ async def next_func(): assert isinstance(middleware, Callable) finally: - cleanup_mock_web_api_server(self) + 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/scenario_tests_async/test_attachment_actions.py b/tests/scenario_tests_async/test_attachment_actions.py index 34110bd38..ea07e6ed0 100644 --- a/tests/scenario_tests_async/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,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,35 +60,47 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("pick_channel_for_fun")(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success(self): - 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): @@ -98,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): @@ -118,51 +133,63 @@ async def test_process_before_response_2(self): request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure_without_type(self): - 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("unknown")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - 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({"callback_id": "unknown", "type": "interactive_message",})( - simple_listener - ) + app.action( + { + "callback_id": "unknown", + "type": "interactive_message", + } + )(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + 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"] == 1 + await assert_auth_test_count_async(self, 1) -# https://api.slack.com/legacy/interactive-messages +# https://docs.slack.dev/legacy/legacy-messaging/legacy-making-messages-interactive/ body = { "type": "interactive_message", "actions": [ diff --git a/tests/scenario_tests_async/test_authorize.py b/tests/scenario_tests_async/test_authorize.py index 83e7d45a3..9d6e3d4af 100644 --- a/tests/scenario_tests_async/test_authorize.py +++ b/tests/scenario_tests_async/test_authorize.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -7,12 +6,15 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt import BoltResponse from slack_bolt.app.async_app import AsyncApp from slack_bolt.authorization import AuthorizeResult from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.request.payload_utils import is_event from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + 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 @@ -26,7 +28,8 @@ async def authorize(enterprise_id, team_id, user_id, client: AsyncWebClient): assert user_id == "W99999" auth_test = await client.auth_test(token=valid_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, bot_token=valid_token, + auth_test_response=auth_test, + bot_token=valid_token, ) @@ -36,7 +39,8 @@ async def user_authorize(enterprise_id, team_id, user_id, client: AsyncWebClient assert user_id == "W99999" auth_test = await client.auth_test(token=valid_user_token) return AuthorizeResult.from_auth_test_response( - auth_test_response=auth_test, user_token=valid_user_token, + auth_test_response=auth_test, + user_token=valid_user_token, ) @@ -51,23 +55,25 @@ class TestAsyncAuthorize: signing_secret = "secret" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @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): @@ -77,11 +83,38 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> AsyncBoltRequest: + def build_block_actions_request(self) -> AsyncBoltRequest: + timestamp = str(int(time())) + return AsyncBoltRequest(body=block_actions_raw_body, headers=self.build_headers(timestamp, block_actions_raw_body)) + + def build_message_changed_event_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) + 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): @@ -92,11 +125,11 @@ async def test_success(self): ) app.action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): @@ -107,11 +140,11 @@ async def test_failure(self): ) app.block_action("a")(simple_listener) - request = self.build_valid_request() + request = self.build_block_actions_request() response = await app.async_dispatch(request) assert response.status == 200 - assert response.body == ":x: Please install this app into the workspace :bow:" - assert self.mock_received_requests.get("/auth.test") == None + assert response.body == "" + assert await self.received_requests.get_async("/auth.test", 0) == 0 @pytest.mark.asyncio async def test_bot_context_attributes(self): @@ -122,11 +155,11 @@ async def test_bot_context_attributes(self): ) app.action("a")(assert_bot_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_user_context_attributes(self): @@ -137,14 +170,41 @@ async def test_user_context_attributes(self): ) app.action("a")(assert_user_context_attributes) - request = self.build_valid_request() + request = self.build_block_actions_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_user_context_attributes_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 == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) + + request = self.build_message_changed_event_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == "as expected" + await assert_auth_test_count_async(self, 1) # should be skipped -body = { +block_actions_body = { "type": "block_actions", "user": { "id": "W99999", @@ -181,7 +241,7 @@ async def test_user_context_attributes(self): ], } -raw_body = f"payload={quote(json.dumps(body))}" +block_actions_raw_body = f"payload={quote(json.dumps(block_actions_body))}" async def simple_listener(ack, body, payload, action): diff --git a/tests/scenario_tests_async/test_block_actions.py b/tests/scenario_tests_async/test_block_actions.py index 3eab11552..6441b2e4b 100644 --- a/tests/scenario_tests_async/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 @@ -11,8 +10,11 @@ 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 @@ -22,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): @@ -50,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): @@ -61,53 +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 = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action({"action_id": "a", "block_id": "b"})(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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 = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action({"action_id": "a"})(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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 = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action({"action_id": "a", "block_id": "bbb"})(simple_listener) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -121,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): @@ -135,33 +165,39 @@ 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"] == 1 + 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"] == 1 + await assert_auth_test_count_async(self, 1) body = { 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/scenario_tests_async/test_block_suggestion.py b/tests/scenario_tests_async/test_block_suggestion.py index b6f7aa80c..2450957f4 100644 --- a/tests/scenario_tests_async/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,46 +138,85 @@ 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"] == 1 + 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"] == 1 + 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"] == 1 + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_empty_options(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_options) + + request = self.build_valid_multi_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == """{"options": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + await assert_auth_test_count_async(self, 1) + + @pytest.mark.asyncio + async def test_empty_option_groups(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.options("mes_a")(show_empty_option_groups) + + request = self.build_valid_multi_request() + response = await app.async_dispatch(request) + assert response.status == 200 + assert response.body == """{"option_groups": []}""" + assert response.headers["content-type"][0] == "application/json;charset=utf-8" + await assert_auth_test_count_async(self, 1) body = { @@ -253,9 +299,7 @@ async def test_failure_multi(self): 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 = { @@ -288,3 +332,15 @@ async def show_multi_options(ack, body, payload, options): assert body == options assert payload == options await ack(multi_response) + + +async def show_empty_options(ack, body, payload, options): + assert body == options + assert payload == options + await ack(options=[]) + + +async def show_empty_option_groups(ack, body, payload, options): + assert body == options + assert payload == options + await ack(option_groups=[]) diff --git a/tests/scenario_tests_async/test_dialogs.py b/tests/scenario_tests_async/test_dialogs.py index 2f99d468f..110fca45a 100644 --- a/tests/scenario_tests_async/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): @@ -58,7 +60,10 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_success_without_type(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.options("dialog-callback-id")(handle_suggestion) app.action("dialog-callback-id")(handle_submission_or_cancellation) @@ -67,55 +72,55 @@ async def test_success_without_type(self): assert response.status == 200 assert response.body != "" assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) - 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 = 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"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_2(self): - 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) @@ -125,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"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -146,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"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_2(self): @@ -191,142 +190,163 @@ async def test_process_before_response_2(self): assert response.status == 200 assert response.body == json.dumps(options_response) assert response.headers["content-type"][0] == "application/json;charset=utf-8" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(submission_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(cancellation_raw_body) response = await app.async_dispatch(request) assert response.status == 200 assert response.body == "" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_suggestion_failure_without_type(self): - 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.options("dialog-callback-iddddd")(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_suggestion_failure(self): - 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"] == 1 + 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 - 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.options({"type": "dialog_suggestion", "callback_id": "dialog-callback-iddddd"})(handle_suggestion) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_submission_failure_without_type(self): - 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("dialog-callback-iddddd")(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_submission_failure(self): - 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"] == 1 + 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 - 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({"type": "dialog_submission", "callback_id": "dialog-callback-iddddd"})(handle_submission) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_cancellation_failure_without_type(self): - 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("dialog-callback-iddddd")(handle_cancellation) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_cancellation_failure(self): - 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"] == 1 + 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"] == 1 + await assert_auth_test_count_async(self, 1) suggestion_body = { @@ -402,7 +422,10 @@ async def handle_submission(ack): "value": "UXD-342", }, {"label": "[FE-459] Remove the marquee tag", "value": "FE-459"}, - {"label": "[FE-238] Too many shades of gray in master CSS", "value": "FE-238",}, + { + "label": "[FE-238] Too many shades of gray in master CSS", + "value": "FE-238", + }, ] } diff --git a/tests/scenario_tests_async/test_error_handler.py b/tests/scenario_tests_async/test_error_handler.py index 6a599c5a0..fb0b9ddae 100644 --- a/tests/scenario_tests_async/test_error_handler.py +++ b/tests/scenario_tests_async/test_error_handler.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -7,11 +6,15 @@ 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 @@ -21,18 +24,19 @@ class TestAsyncErrorHandler: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @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) # ---------------- @@ -41,7 +45,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -54,11 +59,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -74,9 +83,7 @@ def build_valid_request(self) -> AsyncBoltRequest: } raw_body = f"payload={quote(json.dumps(body))}" 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)) # ---------------- # tests @@ -87,7 +94,10 @@ async def test_default(self): async def failing_listener(): raise Exception("Something wrong!") - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.action("a")(failing_listener) request = self.build_valid_request() @@ -103,7 +113,10 @@ async def error_handler(logger, payload, response): async def failing_listener(): raise Exception("Something wrong!") - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.error(error_handler) app.action("a")(failing_listener) @@ -149,3 +162,122 @@ async def failing_listener(): response = await app.async_dispatch(request) assert response.status == 500 assert response.headers["x-test-result"] == ["1"] + + @pytest.mark.asyncio + async def test_unhandled_errors(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_process_before_response(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "unhandled request"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_no_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + ) + + @app.middleware + async def broken_middleware(): + pass + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @pytest.mark.asyncio + async def test_unhandled_errors_process_before_response_no_next(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + raise_error_for_unhandled_request=True, + process_before_response=True, + ) + + @app.middleware + async def broken_middleware(): + pass + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == '{"error": "no next() calls in middleware"}' + + @app.error + async def handle_errors(error): + assert isinstance(error, BoltUnhandledRequestError) + return BoltResponse(status=404, body="TODO") + + response = await app.async_dispatch(self.build_valid_request()) + assert response.status == 404 + assert response.body == "TODO" + + @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 index 66322cfa2..0cdaa0fac 100644 --- a/tests/scenario_tests_async/test_events.py +++ b/tests/scenario_tests_async/test_events.py @@ -1,5 +1,7 @@ import asyncio import json +import re +from functools import wraps from random import random from time import time @@ -8,10 +10,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.context.say.async_say import AsyncSay from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + 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 @@ -21,23 +27,25 @@ class TestAsyncEvents: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @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): @@ -58,15 +66,17 @@ async def test_mock_server_is_running(self): @pytest.mark.asyncio async def test_app_mention(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("app_mention")(whats_up) request = self.build_valid_app_mention_request() 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 + 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): @@ -80,9 +90,8 @@ async def test_process_before_response(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - # no sleep here - assert self.mock_received_requests["/chat.postMessage"] == 1 + 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): @@ -92,11 +101,14 @@ async def test_middleware_skip(self): request = self.build_valid_app_mention_request() response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_simultaneous_requests(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("app_mention")(random_sleeper) request = self.build_valid_app_mention_request() @@ -110,8 +122,8 @@ async def test_simultaneous_requests(self): # 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 + 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) @@ -119,19 +131,24 @@ def build_valid_reaction_added_request(self) -> AsyncBoltRequest: @pytest.mark.asyncio async def test_reaction_added(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("reaction_added")(whats_up) request = self.build_valid_reaction_added_request() 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 + 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 = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.event("reaction_added")(always_failing) for _ in range(10): @@ -139,6 +156,449 @@ async def test_stable_auto_ack(self): 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", 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..edc77ecf3 --- /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": "D111", + "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": "D111", + "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..7ec9d0cce --- /dev/null +++ b/tests/scenario_tests_async/test_events_ignore_self.py @@ -0,0 +1,151 @@ +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", + }, + "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..abf3ffb48 --- /dev/null +++ b/tests/scenario_tests_async/test_function.py @@ -0,0 +1,347 @@ +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 + +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 + + +async def fake_sleep(seconds): + pass + + +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: Mock, sleep_mock: MagicMock): + monkeypatch.setattr(time, "time", time_mock) + monkeypatch.setattr(asyncio, "sleep", sleep_mock) + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test() + 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) + + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=MagicMock(side_effect=fake_sleep), + ) + + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + assert f"WARNING {just_no_ack.__name__} didn't call ack()" in caplog.text + + @pytest.mark.asyncio + async def test_function_handler_timeout(self, monkeypatch): + 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) + + sleep_mock = MagicMock(side_effect=fake_sleep) + self.setup_time_mocks( + monkeypatch=monkeypatch, + time_mock=Mock(side_effect=[current_time for current_time in range(100)]), + sleep_mock=sleep_mock, + ) + + response = await app.async_dispatch(request) + + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + assert ( + sleep_mock.call_count == timeout + ), f"Expected handler to time out after calling time.sleep 5 times, but it was called {sleep_mock.call_count} times" + + @pytest.mark.asyncio + async def test_warning_when_timeout_improperly_set(self, caplog): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(just_no_ack) + assert "WARNING" not in caplog.text + + timeout_argument_name = "ack_timeout" + kwargs = {timeout_argument_name: 5} + + callback_id = "reverse1" + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f'WARNING On @app.function("{callback_id}"), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused' + in caplog.text + ) + + callback_id = re.compile(r"hello \w+") + app.function(callback_id, **kwargs)(just_no_ack) + assert ( + f"WARNING On @app.function({callback_id}), as `auto_acknowledge` is `True`, `{timeout_argument_name}={kwargs[timeout_argument_name]}` you gave will be unused" + in caplog.text + ) + + +function_body = { + "token": "verification_token", + "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 index 229c7709b..8c4182f45 100644 --- a/tests/scenario_tests_async/test_lazy.py +++ b/tests/scenario_tests_async/test_lazy.py @@ -10,8 +10,9 @@ from slack_bolt.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + 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 @@ -21,18 +22,19 @@ class TestAsyncLazy: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @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) # ---------------- @@ -41,7 +43,8 @@ def event_loop(self): def generate_signature(self, body: str, timestamp: str): return self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) def build_headers(self, timestamp: str, body: str): @@ -54,11 +57,15 @@ def build_headers(self, timestamp: str, body: str): def build_valid_request(self) -> AsyncBoltRequest: body = { "type": "block_actions", - "user": {"id": "W111",}, + "user": { + "id": "W111", + }, "api_app_id": "A111", "token": "verification_token", "trigger_id": "111.222.valid", - "team": {"id": "T111",}, + "team": { + "id": "T111", + }, "channel": {"id": "C111", "name": "test-channel"}, "response_url": "https://hooks.slack.com/actions/T111/111/random-value", "actions": [ @@ -74,9 +81,7 @@ def build_valid_request(self) -> AsyncBoltRequest: } raw_body = f"payload={quote(json.dumps(body))}" 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)) # ---------------- # tests @@ -95,13 +100,85 @@ async def async2(say): await asyncio.sleep(0.5) await say(text="lazy function 2") - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.action("a")( + ack=just_ack, + lazy=[async1, async2], + ) + + 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], + ack=just_ack, + lazy=[async1, async2], ) request = self.build_valid_request() response = await app.async_dispatch(request) assert response.status == 200 - await asyncio.sleep(1) # wait a bit - assert self.mock_received_requests["/chat.postMessage"] == 2 + 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 index c2d6dda52..374760323 100644 --- a/tests/scenario_tests_async/test_message.py +++ b/tests/scenario_tests_async/test_message.py @@ -7,11 +7,14 @@ from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt import BoltResponse from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + 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 @@ -21,23 +24,25 @@ class TestAsyncMessage: valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" signature_verifier = SignatureVerifier(signing_secret) - web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) - @pytest.fixture - def event_loop(self): + @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,83 +52,220 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_request(self) -> AsyncBoltRequest: + def build_request_from_body(self, message_body: dict) -> AsyncBoltRequest: timestamp, body = str(int(time())), json.dumps(message_body) return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + def build_request(self) -> AsyncBoltRequest: + return self.build_request_from_body(message_body) + def build_request2(self) -> AsyncBoltRequest: - timestamp, body = str(int(time())), json.dumps(message_body2) - return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + return self.build_request_from_body(message_body2) + + 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 = 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 + 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 = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.message("We've received ([0-9]+) messages from (.+)!")(verify_matches) request = self.build_request2() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - await asyncio.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + 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 + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, ) + app.message(re.compile("We've received ([0-9]+) messages from (.+)!"))(verify_matches) request = self.build_request2() response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - await asyncio.sleep(1) # wait a bit after auto ack() - assert self.mock_received_requests["/chat.postMessage"] == 1 + 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 = 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 + 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 = 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 + 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 = 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 + 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 = { @@ -189,7 +331,36 @@ async def whats_up(body, say): } +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 index 3b81a387f..f8dfe9623 100644 --- a/tests/scenario_tests_async/test_middleware.py +++ b/tests/scenario_tests_async/test_middleware.py @@ -1,37 +1,47 @@ -import asyncio 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 ( - 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 +# 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,) + 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 build_request(self) -> AsyncBoltRequest: @@ -56,7 +66,8 @@ def build_request(self) -> AsyncBoltRequest: "content-type": ["application/json"], "x-slack-signature": [ self.signature_verifier.generate_signature( - body=body, timestamp=timestamp, + body=body, + timestamp=timestamp, ) ], "x-slack-request-timestamp": [timestamp], @@ -65,24 +76,101 @@ def build_request(self) -> AsyncBoltRequest: @pytest.mark.asyncio async def test_no_next_call(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(no_next) app.shortcut("test-shortcut")(just_ack) response = await app.async_dispatch(self.build_request()) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_next_call(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.use(just_next) app.shortcut("test-shortcut")(just_ack) response = await app.async_dispatch(self.build_request()) assert response.status == 200 assert response.body == "acknowledged!" - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) + + @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): @@ -95,3 +183,51 @@ async def no_next(): 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/scenario_tests_async/test_shortcut.py b/tests/scenario_tests_async/test_shortcut.py index aac7cde89..bd3c595eb 100644 --- a/tests/scenario_tests_async/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): @@ -59,75 +61,88 @@ async def test_mock_server_is_running(self): # NOTE: This is a compatible behavior with Bolt for JS @pytest.mark.asyncio async def test_success_both_global_and_message(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_global(self): - app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret,) + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) app.shortcut("test-shortcut")(simple_listener) request = self.build_valid_request(global_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_success_global_2(self): - 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"] == 1 + 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"] == 1 + 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"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response_global(self): @@ -141,38 +156,44 @@ 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"] == 1 + 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"] == 1 + await assert_auth_test_count_async(self, 1) request = self.build_valid_request(message_shortcut_raw_body) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) global_shortcut_body = { diff --git a/tests/scenario_tests_async/test_slash_command.py b/tests/scenario_tests_async/test_slash_command.py index 84d8a44eb..1ac02bce7 100644 --- a/tests/scenario_tests_async/test_slash_command.py +++ b/tests/scenario_tests_async/test_slash_command.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time @@ -9,8 +8,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 @@ -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): @@ -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,20 +82,23 @@ 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"] == 1 + await assert_auth_test_count_async(self, 1) slash_command_body = ( diff --git a/tests/scenario_tests_async/test_ssl_check.py b/tests/scenario_tests_async/test_ssl_check.py index 3a22e85b8..ef32bc0dd 100644 --- a/tests/scenario_tests_async/test_ssl_check.py +++ b/tests/scenario_tests_async/test_ssl_check.py @@ -1,4 +1,3 @@ -import asyncio from time import time import pytest @@ -8,8 +7,8 @@ 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, + 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/scenario_tests_async/test_view_closed.py b/tests/scenario_tests_async/test_view_closed.py index 020301284..1b86d22db 100644 --- a/tests/scenario_tests_async/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,46 +96,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.view("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_failure(self): - 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"] == 1 + 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"] == 1 + await assert_auth_test_count_async(self, 1) body = { @@ -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", diff --git a/tests/scenario_tests_async/test_view_submission.py b/tests/scenario_tests_async/test_view_submission.py index 4c5fa672d..6511243fa 100644 --- a/tests/scenario_tests_async/test_view_submission.py +++ b/tests/scenario_tests_async/test_view_submission.py @@ -1,4 +1,3 @@ -import asyncio import json from time import time from urllib.parse import quote @@ -10,34 +9,208 @@ 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 +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,) + 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,11 +220,9 @@ def build_headers(self, timestamp: str, body: str): "x-slack-request-timestamp": [timestamp], } - def build_valid_request(self) -> AsyncBoltRequest: + def build_valid_request(self, body: str = raw_body) -> AsyncBoltRequest: timestamp = str(int(time())) - return AsyncBoltRequest( - body=raw_body, headers=self.build_headers(timestamp, raw_body) - ) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) @pytest.mark.asyncio async def test_mock_server_is_running(self): @@ -60,23 +231,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("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_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 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_process_before_response(self): @@ -90,87 +267,69 @@ 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"] == 1 + 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_submission("view-idddd")(simple_listener) response = await app.async_dispatch(request) assert response.status == 404 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) + @pytest.mark.asyncio + async def test_response_urls(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) -body = { - "type": "view_submission", - "team": { - "id": "T111", - "domain": "workspace-domain", - "enterprise_id": "E111", - "enterprise_name": "Sandbox Org", - }, - "user": { - "id": "W111", - "username": "primary-owner", - "name": "primary-owner", - "team_id": "T111", - }, - "api_app_id": "A111", - "token": "verification_token", - "trigger_id": "111.222.valid", - "view": { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "input", - "block_id": "hspI", - "label": {"type": "plain_text", "text": "Label",}, - "optional": False, - "element": {"type": "plain_text_input", "action_id": "maBWU"}, - } - ], - "private_metadata": "This is for you!", - "callback_id": "view-id", - "state": { - "values": {"hspI": {"maBWU": {"type": "plain_text_input", "value": "test"}}} - }, - "hash": "1596530361.3wRYuk3R", - "title": {"type": "plain_text", "text": "My App",}, - "clear_on_close": False, - "notify_on_close": False, - "close": {"type": "plain_text", "text": "Cancel",}, - "submit": {"type": "plain_text", "text": "Submit",}, - "previous_view_id": None, - "root_view_id": "V111", - "app_id": "A111", - "external_id": "", - "app_installed_team_id": "T111", - "bot_id": "B111", - }, - "response_urls": [], -} + @app.view("view-id") + async def check(ack, respond): + await respond("Hi") + await ack() -raw_body = f"payload={quote(json.dumps(body))}" + request = self.build_valid_request(raw_response_url_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + + @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): @@ -179,3 +338,8 @@ async def simple_listener(ack, body, payload, view): 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 index de92b5b55..a99dedbfe 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -1,5 +1,5 @@ -import asyncio import json +import logging from time import time from urllib.parse import quote @@ -16,45 +16,63 @@ from slack_bolt.workflows.step.utilities.async_fail import AsyncFail from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + 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: +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,) + 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) - self.app = AsyncApp( - client=self.web_client, signing_secret=self.signing_secret - ) - self.app.step( - callback_id="copy_review", edit=edit, save=save, execute=execute - ) - - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + 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_app(self, callback_id: str): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.step(callback_id=callback_id, edit=edit, save=save, execute=execute) + return app + + def build_process_before_response_app(self, callback_id: str): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + process_before_response=True, + ) + app.step( + callback_id=callback_id, + edit=[edit_ack, edit_lazy], + save=[save_ack, save_lazy], + execute=[execute_ack, execute_lazy], ) + return app @pytest.mark.asyncio async def test_edit(self): + app = self.build_app("copy_review") + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" headers = { "content-type": ["application/x-www-form-urlencoded"], @@ -62,19 +80,38 @@ async def test_edit(self): "x-slack-request-timestamp": [timestamp], } request = AsyncBoltRequest(body=body, headers=headers) - response = await self.app.async_dispatch(request) + response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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) + 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"], @@ -82,19 +119,38 @@ async def test_save(self): "x-slack-request-timestamp": [timestamp], } request = AsyncBoltRequest(body=body, headers=headers) - response = await self.app.async_dispatch(request) + response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + 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) + 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"], @@ -102,19 +158,95 @@ async def test_execute(self): "x-slack-request-timestamp": [timestamp], } request = AsyncBoltRequest(body=body, headers=headers) - response = await self.app.async_dispatch(request) + response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 - await asyncio.sleep(0.5) - assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + 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) + 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", @@ -313,7 +445,10 @@ async def edit(ack: AsyncAck, step, configure: AsyncConfigure): "element": { "type": "plain_text_input", "action_id": "task_name", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task name"}, }, @@ -336,7 +471,10 @@ async def edit(ack: AsyncAck, step, configure: AsyncConfigure): "element": { "type": "plain_text_input", "action_id": "task_author", - "placeholder": {"type": "plain_text", "text": "Write a task name",}, + "placeholder": { + "type": "plain_text", + "text": "Write a task name", + }, }, "label": {"type": "plain_text", "text": "Task author"}, }, @@ -354,18 +492,28 @@ async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): "value": state_values["task_name_input"]["task_name"]["value"], }, "taskDescription": { - "value": state_values["task_description_input"]["task_description"][ - "value" - ], + "value": state_values["task_description_input"]["task_description"]["value"], }, "taskAuthorEmail": { "value": state_values["task_author_input"]["task_author"]["value"], }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name",}, - {"name": "taskDescription", "type": "text", "label": "Task Description",}, - {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + { + "name": "taskName", + "type": "text", + "label": "Task Name", + }, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, ], ) await ack() @@ -374,9 +522,7 @@ async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): pseudo_database = {} -async def execute( - step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail -): +async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): assert step is not None try: await complete( @@ -387,9 +533,7 @@ async def execute( } ) - user: SlackResponse = await client.users_lookupByEmail( - email=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"], @@ -419,3 +563,37 @@ async def execute( ) except Exception as err: await fail(error={"message": f"Something wrong! {err}"}) + + +async def edit_ack(ack: AsyncAck): + await ack() + + +async def edit_lazy(step, configure: AsyncConfigure): + assert step is not None + await configure(blocks=[]) + + +async def save_ack(ack: AsyncAck): + await ack() + + +async def save_lazy(step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + await update( + inputs={}, + outputs=[], + ) + + +async def execute_ack(): + pass + + +async def execute_lazy(step: dict, complete: AsyncComplete, fail: AsyncFail): + assert step is not None + try: + await complete(outputs={}) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) 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/app/test_dev_server.py b/tests/slack_bolt/app/test_dev_server.py index 7e09c6102..978cffc3f 100644 --- a/tests/slack_bolt/app/test_dev_server.py +++ b/tests/slack_bolt/app/test_dev_server.py @@ -12,7 +12,10 @@ class TestDevServer: signing_secret = "secret" valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url,) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) def setup_method(self): self.old_os_env = remove_os_env_temporarily() diff --git a/tests/slack_bolt/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/test_ack.py b/tests/slack_bolt/context/test_ack.py index 9ecb17a69..25dbb00a5 100644 --- a/tests/slack_bolt/context/test_ack.py +++ b/tests/slack_bolt/context/test_ack.py @@ -54,16 +54,26 @@ def test_blocks(self): '{"text": "foo", "blocks": [{"type": "divider"}]}', ) + def test_unfurl_options(self): + ack = Ack() + response: BoltResponse = ack( + text="foo", + blocks=[{"type": "divider"}], + unfurl_links=True, + unfurl_media=True, + ) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "unfurl_links": true, "unfurl_media": true, "blocks": [{"type": "divider"}]}', + ) + sample_options = [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}] def test_options(self): 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"}]}' - ) + assert response.body == '{"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}' sample_option_groups = [ { @@ -83,9 +93,7 @@ def test_options(self): def test_option_groups(self): ack = Ack() - response: BoltResponse = ack( - text="foo", option_groups=self.sample_option_groups - ) + response: BoltResponse = ack(text="foo", option_groups=self.sample_option_groups) assert response.status == 200 assert response.body.startswith('{"option_groups":') @@ -138,8 +146,14 @@ def test_view_update(self): view={ "type": "modal", "callback_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [{"type": "divider", "block_id": "b"}], }, ) diff --git a/tests/slack_bolt/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 index 1a76e507d..88e98dec8 100644 --- a/tests/slack_bolt/context/test_respond.py +++ b/tests/slack_bolt/context/test_respond.py @@ -23,3 +23,19 @@ def test_respond2(self): respond = Respond(response_url=response_url) response = respond({"text": "Hi there!"}) assert response.status_code == 200 + + def test_unfurl_options(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond(text="Hi there!", unfurl_media=True, unfurl_links=True) + assert response.status_code == 200 + + def test_metadata(self): + response_url = "http://localhost:8888" + respond = Respond(response_url=response_url) + response = respond( + text="Hi there!", + response_type="in_channel", + metadata={"event_type": "foo", "event_payload": {"foo": "bar"}}, + ) + assert response.status_code == 200 diff --git a/tests/slack_bolt/context/test_respond_internals.py b/tests/slack_bolt/context/test_respond_internals.py index 173fdf003..7d497e205 100644 --- a/tests/slack_bolt/context/test_respond_internals.py +++ b/tests/slack_bolt/context/test_respond_internals.py @@ -43,3 +43,17 @@ def test_build_message_replace_original(self): def test_build_message_delete_original(self): message = _build_message(delete_original=True) assert message is not None + + def test_build_message_unfurl_options(self): + message = _build_message(text="Hi there!", unfurl_links=True, unfurl_media=True) + assert message is not None + assert message.get("unfurl_links") is True + assert message.get("unfurl_media") is True + + 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 index a28fbbd9d..6ca1fc96a 100644 --- a/tests/slack_bolt/context/test_say.py +++ b/tests/slack_bolt/context/test_say.py @@ -3,10 +3,7 @@ from slack_sdk.web import SlackResponse from slack_bolt import Say -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server class TestSay: @@ -14,9 +11,7 @@ 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 - ) + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) def teardown_method(self): cleanup_mock_web_api_server(self) @@ -26,6 +21,21 @@ def test_say(self): response: SlackResponse = say(text="Hi there!") assert response.status_code == 200 + def test_say_markdown_text(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(markdown_text="**Greetings!**") + assert response.status_code == 200 + + def test_say_unfurl_options(self): + say = Say(client=self.web_client, channel="C111") + response: SlackResponse = say(text="Hi there!", unfurl_media=True, unfurl_links=True) + 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!"}) @@ -40,3 +50,16 @@ 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..29d244a65 --- /dev/null +++ b/tests/slack_bolt/context/test_say_stream.py @@ -0,0 +1,91 @@ +import pytest +from slack_sdk import WebClient + +from slack_bolt.context.say_stream.say_stream import SayStream +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSayStream: + default_chat_stream_buffer_size = WebClient.chat_stream.__kwdefaults__["buffer_size"] + + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + 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", + ) + stream = say_stream() + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C111", + "thread_ts": "111.222", + "recipient_team_id": "T111", + "recipient_user_id": "U111", + "task_display_mode": None, + } + + def test_parameter_overrides(self): + say_stream = SayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } + + def test_buffer_size_overrides(self): + say_stream = SayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + + assert stream._buffer_size == 100 + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } diff --git a/tests/slack_bolt/context/test_set_status.py b/tests/slack_bolt/context/test_set_status.py new file mode 100644 index 000000000..fe998df5e --- /dev/null +++ b/tests/slack_bolt/context/test_set_status.py @@ -0,0 +1,38 @@ +import pytest +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt.context.set_status import SetStatus +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSetStatus: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_set_status(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_status("Thinking...") + assert response.status_code == 200 + + def test_set_status_loading_messages(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_status( + status="Thinking...", + loading_messages=[ + "Sitting...", + "Waiting...", + ], + ) + assert response.status_code == 200 + + def test_set_status_invalid(self): + set_status = SetStatus(client=self.web_client, channel_id="C111", thread_ts="123.123") + with pytest.raises(TypeError): + set_status() diff --git a/tests/slack_bolt/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py new file mode 100644 index 000000000..792b974b5 --- /dev/null +++ b/tests/slack_bolt/context/test_set_suggested_prompts.py @@ -0,0 +1,37 @@ +import pytest +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server + + +class TestSetSuggestedPrompts: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_set_suggested_prompts(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_suggested_prompts(prompts=["One", "Two"]) + assert response.status_code == 200 + + def test_set_suggested_prompts_objects(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + response: SlackResponse = set_suggested_prompts( + prompts=[ + "One", + {"title": "Two", "message": "What's before addition?"}, + ], + ) + assert response.status_code == 200 + + def test_set_suggested_prompts_invalid(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + with pytest.raises(TypeError): + set_suggested_prompts() diff --git a/tests/slack_bolt/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/test_args.py b/tests/slack_bolt/kwargs_injection/test_args.py index dbee91bbe..530a5a703 100644 --- a/tests/slack_bolt/kwargs_injection/test_args.py +++ b/tests/slack_bolt/kwargs_injection/test_args.py @@ -26,6 +26,8 @@ def test_build(self): "ack", "say", "respond", + "complete", + "fail", "next", ] arg_params: dict = build_required_kwargs( 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/test_builtins.py b/tests/slack_bolt/listener_matcher/test_builtins.py index f8185d938..4bb353913 100644 --- a/tests/slack_bolt/listener_matcher/test_builtins.py +++ b/tests/slack_bolt/listener_matcher/test_builtins.py @@ -7,6 +7,8 @@ block_action, action, workflow_step_execute, + event, + shortcut, ) @@ -48,57 +50,23 @@ def test_block_action(self): assert action({"action_id": "valid_action_id"}).matches(req, resp) is True assert action({"action_id": "invalid_action_id"}).matches(req, resp) is False assert action({"action_id": re.compile("valid_.+")}).matches(req, resp) is True - assert ( - action({"action_id": re.compile("invalid_.+")}).matches(req, resp) is False - ) - - assert ( - action({"action_id": "valid_action_id", "block_id": "b"}).matches(req, resp) - is True - ) - assert ( - action({"action_id": "invalid_action_id", "block_id": "b"}).matches( - req, resp - ) - is False - ) - assert ( - action({"action_id": re.compile("valid_.+"), "block_id": "b"}).matches( - req, resp - ) - is True - ) - assert ( - action({"action_id": re.compile("invalid_.+"), "block_id": "b"}).matches( - req, resp - ) - is False - ) - - assert ( - action({"action_id": "valid_action_id", "block_id": "bbb"}).matches( - req, resp - ) - is False - ) - assert ( - action({"action_id": "invalid_action_id", "block_id": "bbb"}).matches( - req, resp - ) - is False - ) - assert ( - action({"action_id": re.compile("valid_.+"), "block_id": "bbb"}).matches( - req, resp - ) - is False - ) - assert ( - action({"action_id": re.compile("invalid_.+"), "block_id": "bbb"}).matches( - req, resp - ) - is False - ) + 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 = { @@ -114,9 +82,7 @@ def test_workflow_step_execute(self): "workflow_instance_id": "11111", "step_id": "111-222-333-444-555", "inputs": {"taskName": {"value": "a"}}, - "outputs": [ - {"name": "taskName", "type": "text", "label": "Task Name"} - ], + "outputs": [{"name": "taskName", "type": "text", "label": "Task Name"}], }, "event_ts": "1601541373.225894", }, @@ -135,3 +101,135 @@ def test_workflow_step_execute(self): m = workflow_step_execute(re.compile("copy_.+")) assert m.matches(request, None) == True + + def test_events(self): + request = BoltRequest(body=json.dumps(event_payload)) + + m = event("app_mention") + assert m.matches(request, None) + m = event({"type": "app_mention"}) + assert m.matches(request, None) + m = event("message") + assert not m.matches(request, None) + m = event({"type": "message"}) + assert not m.matches(request, None) + + request = BoltRequest(body=f"payload={quote(json.dumps(shortcut_payload))}") + + m = event("app_mention") + assert not m.matches(request, None) + m = event({"type": "app_mention"}) + assert not m.matches(request, None) + + def test_global_shortcuts(self): + request = BoltRequest(body=f"payload={quote(json.dumps(shortcut_payload))}") + + m = shortcut("test-shortcut") + assert m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "shortcut"}) + assert m.matches(request, None) + + m = shortcut("test-shortcut!!!") + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "message_action"}) + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut!!!", "type": "shortcut"}) + assert not m.matches(request, None) + + def test_message_shortcuts(self): + request = BoltRequest(body=f"payload={quote(json.dumps(message_shortcut_payload))}") + + m = shortcut("test-shortcut") + assert m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "message_action"}) + assert m.matches(request, None) + + m = shortcut("test-shortcut!!!") + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut", "type": "shortcut"}) + assert not m.matches(request, None) + m = shortcut({"callback_id": "test-shortcut!!!", "type": "message_action"}) + assert not m.matches(request, None) + + +event_payload = { + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": True, + } + ], +} + +shortcut_payload = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", +} + + +message_shortcut_payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", +} diff --git a/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py index e241298d5..5a680f137 100644 --- a/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py +++ b/tests/slack_bolt/listener_matcher/test_custom_listener_matcher.py @@ -19,7 +19,8 @@ def teardown_method(self): def test_instantiation(self): matcher: ListenerMatcher = CustomListenerMatcher( - app_name="foo", func=func, + app_name="foo", + func=func, ) resp = BoltResponse(status=201) diff --git a/tests/slack_bolt/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/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..b7785eb50 --- /dev/null +++ b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py @@ -0,0 +1,72 @@ +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 ( + 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") + + +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_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/test_single_team_authorization.py b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py index d506709a0..7e3a0c11d 100644 --- a/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt/middleware/authorization/test_single_team_authorization.py @@ -1,6 +1,8 @@ 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 ( @@ -25,25 +27,55 @@ def teardown_method(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" + 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" - ) + 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 == ":x: Please install this app into the workspace :bow:" + 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..2c9adea43 --- /dev/null +++ b/tests/slack_bolt/middleware/request_verification/test_request_verification.py @@ -0,0 +1,47 @@ +from time import time + +from slack_sdk.signature import SignatureVerifier + +from slack_bolt.middleware import RequestVerification +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def next(): + return BoltResponse(status=200, body="next") + + +class TestRequestVerification: + signing_secret = "secret" + signature_verifier = SignatureVerifier(signing_secret) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def test_valid(self): + middleware = RequestVerification(signing_secret=self.signing_secret) + timestamp = str(int(time())) + raw_body = "payload={}" + req = BoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) + resp = BoltResponse(status=404, body="default") + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 200 + assert resp.body == "next" + + def test_invalid(self): + middleware = RequestVerification(signing_secret=self.signing_secret) + req = BoltRequest(body="payload={}", headers={}) + resp = BoltResponse(status=404) + resp = middleware.process(req=req, resp=resp, next=next) + assert resp.status == 401 + assert resp.body == """{"error": "invalid request"}""" diff --git a/tests/slack_bolt/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 index e228890b9..77258685d 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow.py +++ b/tests/slack_bolt/oauth/test_oauth_flow.py @@ -1,11 +1,13 @@ import json -import re from time import time from urllib.parse import quote from slack_sdk import WebClient from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.oauth.state_store import ( + OAuthStateStore, + FileOAuthStateStore, +) from slack_sdk.signature import SignatureVerifier from slack_bolt import BoltRequest, BoltResponse, App @@ -15,6 +17,7 @@ from tests.mock_web_api_server import ( cleanup_mock_web_api_server, setup_mock_web_api_server, + assert_auth_test_count, ) @@ -41,29 +44,92 @@ def test_instantiation(self): assert oauth_flow.logger is not None assert oauth_flow.client is not None - def test_handle_installation(self): + def test_handle_installation_default(self): oauth_flow = OAuthFlow( settings=OAuthSettings( client_id="111.222", 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 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None + assert resp.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( @@ -108,15 +174,13 @@ def test_handle_callback(self): 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-signature": [signature_verifier.generate_signature(body=body, timestamp=timestamp)], "x-slack-request-timestamp": [timestamp], } request = BoltRequest(body=body, headers=headers) response = app.dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + assert_auth_test_count(self, 1) def test_handle_callback_invalid_state(self): oauth_flow = OAuthFlow( @@ -137,6 +201,52 @@ def test_handle_callback_invalid_state(self): resp = oauth_flow.handle_callback(req) assert resp.status == 400 + def test_handle_callback_already_expired_state(self): + class MyOAuthStateStore(OAuthStateStore): + def issue(self, *args, **kwargs) -> str: + return "expired_one" + + def consume(self, state: str) -> bool: + return False + + oauth_flow = OAuthFlow( + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=MyOAuthStateStore(), + ) + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 401 + + def test_handle_callback_no_state_validation(self): + oauth_flow = OAuthFlow( + client=WebClient(base_url=self.mock_api_server_base_url), + settings=OAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_validation_enabled=False, # disabled + state_store=None, + ), + ) + state = oauth_flow.issue_new_state(None) + req = BoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = oauth_flow.handle_callback(req) + assert resp.status == 200 + def test_handle_callback_using_options(self): def success(args: SuccessArgs) -> BoltResponse: assert args.request is not None diff --git a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py index 367349cc2..26c54df15 100644 --- a/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py +++ b/tests/slack_bolt/oauth/test_oauth_flow_sqlite3.py @@ -1,5 +1,3 @@ -import re - from slack_sdk import WebClient from slack_bolt import BoltRequest, BoltResponse @@ -41,17 +39,9 @@ def test_handle_installation(self): ) req = BoltRequest(body="") resp = oauth_flow.handle_installation(req) - assert resp.status == 302 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None - ) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body def test_handle_callback(self): oauth_flow = OAuthFlow.sqlite3( diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index fce00681c..8cccf0431 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -2,10 +2,18 @@ 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, ) @@ -49,6 +57,418 @@ def teardown_method(self): }, ] + enterprise_no_channel_requests = [ + { + "type": "shortcut", + "token": "xxx", + "action_ts": "1606983924.521157", + "team": {"id": "T111", "domain": "ddd"}, + "user": {"id": "U111", "username": "use", "team_id": "T111"}, + "is_enterprise_install": False, + "enterprise": {"id": "E111", "domain": "eee"}, + "callback_id": "run-socket-mode", + "trigger_id": "111.222.xxx", + }, + ] + + no_enterprise_no_channel_requests = [ + { + "type": "shortcut", + "token": "xxx", + "action_ts": "1606983924.521157", + "team": {"id": "T111", "domain": "ddd"}, + "user": {"id": "U111", "username": "use", "team_id": "T111"}, + "is_enterprise_install": False, + # This may be "null" in Socket Mode + "enterprise": None, + "callback_id": "run-socket-mode", + "trigger_id": "111.222.xxx", + }, + ] + + 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) @@ -58,16 +478,116 @@ 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: - team_id = extract_enterprise_id(req) - assert team_id == "E111" + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.enterprise_no_channel_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + for req in self.no_enterprise_no_channel_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id is None + 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"]} @@ -83,3 +603,673 @@ def test_parse_query(self): with pytest.raises(ValueError): parse_query({"foo": {"bar": "ZZZ"}, "baz": {"123": "111"}}) + + slack_connect_from_non_grid_test_patterns = [ + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + # context.enterprise_id/team_id/user_id, + (None, "T03E94MJU", "U03E94MK0"), + # context.actor_enterprise_id/team_id/user_id, + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "text": "<@U04T5KKKLUE>", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "U03E94MK0", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "user": "U03E94MK0", + "team": "T014GJXU940", + "user_team": "T03E94MJU", + "source_team": "T03E94MJU", + "user_profile": {"team": "T03E94MJU"}, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "subtype": "channel_join", + "user": "UL5CBM924", + "team": "T03E94MJU", + "inviter": "U03E94MK0", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T03E94MJU", + "context_enterprise_id": None, + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_joined_channel", + "user": "UL5CBM924", + "channel": "C04T3ACM40K", + "team": "T03E94MJU", + "inviter": "U03E94MK0", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T03E94MJU", + "context_enterprise_id": None, + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_left_channel", + "user": "UL5CBM924", + "channel": "C04T3ACM40K", + "team": "T03E94MJU", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "subtype": "channel_leave", + "user": "UL5CBM924", + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "UL5CBM924"), + (None, "T03E94MJU", "UL5CBM924"), + ), + ( + { + "team_id": "T03E94MJU", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "U03E94MK0", + "display_as_bot": False, + "team": "T03E94MJU", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TL3HA3PC", + "user_id": "U03E94MK0", + "file": {"id": "F04TL3HA3PC"}, + "channel_id": "C04T3ACM40K", + "type": "file_shared", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "U03E94MK0"), + # Note that a complete set of actor IDs are not deterministic in this scenario + # So, we fall back to all None data for clarity + (None, None, None), + ), + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TL3HA3PC", + "user_id": "U03E94MK0", + "file": {"id": "F04TL3HA3PC"}, + "type": "file_public", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + }, + (None, "T03E94MJU", "U03E94MK0"), + (None, "T03E94MJU", "U03E94MK0"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message_metadata_posted", + "app_id": "A013TFN1T7C", + "bot_id": "B013ZM43W3E", + "user_id": "W013TN008CB", + "team_id": "T014GJXU940", + "channel_id": "C04T3ACM40K", + "metadata": { + "event_type": "task_created", + "event_payload": {"id": "11223", "title": "Redesign Homepage"}, + }, + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013TN008CB"), + # Note that a complete set of actor IDs are not deterministic in this scenario + # So, we fall back to all None data for clarity + (None, None, None), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "bot_id": "B013ZM43W3E", + "type": "message", + "user": "W013TN008CB", + "metadata": { + "event_type": "task_created", + "event_payload": {"id": "11223", "title": "Redesign Homepage"}, + }, + "app_id": "A013TFN1T7C", + "team": "T014GJXU940", + "bot_profile": { + "id": "B013ZM43W3E", + "app_id": "A013TFN1T7C", + "team_id": "T014GJXU940", + }, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013TN008CB"), + ("E013Y3SHLAY", "T014GJXU940", "W013TN008CB"), + ), + ] + + slack_connect_from_grid_test_patterns = [ + ( + { + "team_id": "T03E94MJU", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "app_mention", + "user": "W013QGS7BPF", + "team": "T03E94MJU", + "user_team": "E013Y3SHLAY", + "source_team": "E013Y3SHLAY", + "user_profile": { + "team": "E013Y3SHLAY", + }, + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + # context.enterprise_id/team_id/user_id, + (None, "T03E94MJU", "W013QGS7BPF"), + # context.actor_enterprise_id/team_id/user_id, + ("E013Y3SHLAY", None, "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "user": "W013QGS7BPF", + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TDEYDCT0", + "user_id": "W013QGS7BPF", + "file": {"id": "F04TDEYDCT0"}, + "channel_id": "C04T3ACM40K", + "type": "file_shared", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + (None, None, None), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "message", + "files": [], + "upload": False, + "user": "W013QGS7BPF", + "display_as_bot": False, + "team": "T014GJXU940", + "channel": "C04T3ACM40K", + "subtype": "file_share", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "file_id": "F04TDEYDCT0", + "user_id": "W013QGS7BPF", + "file": {"id": "F04TDEYDCT0"}, + "type": "file_public", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": "E013Y3SHLAY", + "team_id": "T014GJXU940", + "user_id": "U04TDAM3YUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + }, + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ("E013Y3SHLAY", "T014GJXU940", "W013QGS7BPF"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_joined_channel", + "user": "W013CV5UA87", + "channel": "C04T3ACM40K", + "team": "T014GJXU940", + "inviter": "W013QGS7BPF", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013CV5UA87"), + ("E013Y3SHLAY", "T014GJXU940", "W013CV5UA87"), + ), + ( + { + "team_id": "T014GJXU940", + "enterprise_id": "E013Y3SHLAY", + "context_team_id": "T014GJXU940", + "context_enterprise_id": "E013Y3SHLAY", + "api_app_id": "A04TEM7H4S0", + "event": { + "type": "member_left_channel", + "user": "W013CV5UA87", + "channel": "C04T3ACM40K", + "team": "E013Y3SHLAY", + }, + "type": "event_callback", + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T03E94MJU", + "user_id": "U04T5KKKLUE", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": True, + }, + (None, "T03E94MJU", "W013CV5UA87"), + ("E013Y3SHLAY", "T014GJXU940", "W013CV5UA87"), + ), + ] + + def test_slack_connect_patterns(self): + for ( + request, + (enterprise_id, team_id, user_id), + (actor_enterprise_id, actor_team_id, actor_user_id), + ) in self.slack_connect_from_non_grid_test_patterns: + assert extract_enterprise_id(request) == enterprise_id + assert extract_team_id(request) == team_id + assert extract_user_id(request) == user_id + assert extract_actor_enterprise_id(request) == actor_enterprise_id + assert extract_actor_team_id(request) == actor_team_id + assert extract_actor_user_id(request) == actor_user_id + + for ( + request, + (enterprise_id, team_id, user_id), + (actor_enterprise_id, actor_team_id, actor_user_id), + ) in self.slack_connect_from_grid_test_patterns: + assert extract_enterprise_id(request) == enterprise_id + assert extract_team_id(request) == team_id + assert extract_user_id(request) == user_id + assert extract_actor_enterprise_id(request) == actor_enterprise_id + assert extract_actor_team_id(request) == actor_team_id + assert extract_actor_user_id(request) == actor_user_id + + def test_extraction_functions_invalid_dict_keys(self): + invalid_payloads = { + "event": {"event": "some_event_type"}, + "user": {"user": "U12345"}, + "team": {"team": "T12345"}, + "view": {"view": "V12345"}, + "message": {"message": "some text"}, + "item": {"item": "item_id"}, + "function_data": {"function_data": "fd_123"}, + "assistant_thread": {"assistant_thread": "at_123"}, + "previous_message": {"previous_message": "old_msg"}, + } + + for _, payload in invalid_payloads.items(): + # We only verify no TypeError/AttributeError is raised and that functions which + # would try to subscript the string value return None instead of crashing. + extract_enterprise_id(payload) + extract_team_id(payload) + extract_user_id(payload) + extract_channel_id(payload) + extract_thread_ts(payload) + extract_function_execution_id(payload) + extract_function_bot_access_token(payload) + extract_function_inputs(payload) 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/app/test_server.py b/tests/slack_bolt_async/app/test_server.py index 1c8bbfc29..0c53f96c4 100644 --- a/tests/slack_bolt_async/app/test_server.py +++ b/tests/slack_bolt_async/app/test_server.py @@ -13,6 +13,9 @@ def test_instance(self): server = AsyncSlackAppServer( port=3001, path="/slack/events", - app=AsyncApp(signing_secret="valid", token="xoxb-valid",), + app=AsyncApp( + signing_secret="valid", + token="xoxb-valid", + ), ) assert server is not None diff --git a/tests/slack_bolt_async/authorization/__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/test_async_ack.py b/tests/slack_bolt_async/context/test_async_ack.py index fa74e198f..bbd6db41d 100644 --- a/tests/slack_bolt_async/context/test_async_ack.py +++ b/tests/slack_bolt_async/context/test_async_ack.py @@ -22,6 +22,20 @@ async def test_blocks(self): '{"text": "foo", "blocks": [{"type": "divider"}]}', ) + @pytest.mark.asyncio + async def test_unfurl_options(self): + ack = AsyncAck() + response: BoltResponse = await ack( + text="foo", + blocks=[{"type": "divider"}], + unfurl_links=True, + unfurl_media=True, + ) + assert (response.status, response.body) == ( + 200, + '{"text": "foo", "unfurl_links": true, "unfurl_media": true, "blocks": [{"type": "divider"}]}', + ) + sample_attachments = [ { "fallback": "Plain-text summary of the attachment.", @@ -45,9 +59,7 @@ async def test_blocks(self): @pytest.mark.asyncio async def test_attachments(self): ack = AsyncAck() - response: BoltResponse = await ack( - text="foo", attachments=self.sample_attachments - ) + response: BoltResponse = await ack(text="foo", attachments=self.sample_attachments) assert (response.status, response.body) == ( 200, '{"text": "foo", ' @@ -62,10 +74,7 @@ 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"}]}' - ) + assert response.body == '{"options": [{"text": {"type": "plain_text", "text": "Maru"}, "value": "maru"}]}' sample_option_groups = [ { @@ -86,9 +95,7 @@ async def test_options(self): @pytest.mark.asyncio async def test_option_groups(self): ack = AsyncAck() - response: BoltResponse = await ack( - text="foo", option_groups=self.sample_option_groups - ) + response: BoltResponse = await ack(text="foo", option_groups=self.sample_option_groups) assert response.status == 200 assert response.body.startswith('{"option_groups":') @@ -145,8 +152,14 @@ async def test_view_update(self): view={ "type": "modal", "callbAsyncAck_id": "view-id", - "title": {"type": "plain_text", "text": "My App",}, - "close": {"type": "plain_text", "text": "Cancel",}, + "title": { + "type": "plain_text", + "text": "My App", + }, + "close": { + "type": "plain_text", + "text": "Cancel", + }, "blocks": [{"type": "divider", "block_id": "b"}], }, ) diff --git a/tests/slack_bolt_async/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 index fb3083132..b47ef1056 100644 --- a/tests/slack_bolt_async/context/test_async_respond.py +++ b/tests/slack_bolt_async/context/test_async_respond.py @@ -1,22 +1,24 @@ -import asyncio - 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 ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, ) class TestAsyncRespond: - @pytest.fixture - def event_loop(self): - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + + @pytest.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): @@ -31,3 +33,21 @@ async def test_respond2(self): respond = AsyncRespond(response_url=response_url) response = await respond({"text": "Hi there!"}) assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_respond_unfurl_options(self): + response_url = "http://localhost:8888" + respond = AsyncRespond(response_url=response_url) + response = await respond(text="Hi there!", unfurl_media=True, unfurl_links=True) + assert response.status_code == 200 + + @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 index 03a2978ec..d8d63ae8a 100644 --- a/tests/slack_bolt_async/context/test_async_say.py +++ b/tests/slack_bolt_async/context/test_async_say.py @@ -1,30 +1,26 @@ -import asyncio - import pytest from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_slack_response import AsyncSlackResponse from slack_bolt.context.say.async_say import AsyncSay -from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, -) +from tests.mock_web_api_server import cleanup_mock_web_api_server_async, setup_mock_web_api_server_async +from tests.utils import remove_os_env_temporarily, restore_os_env class TestAsyncSay: - @pytest.fixture - def event_loop(self): - setup_mock_web_api_server(self) + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" - self.web_client = AsyncWebClient( - token=valid_token, base_url=mock_api_server_base_url - ) - - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server_async(self) + restore_os_env(old_os_env) @pytest.mark.asyncio async def test_say(self): @@ -32,6 +28,24 @@ async def test_say(self): response: AsyncSlackResponse = await say(text="Hi there!") assert response.status_code == 200 + @pytest.mark.asyncio + async def test_say_markdown_text(self): + say = AsyncSay(client=self.web_client, channel="C111") + response: AsyncSlackResponse = await say(markdown_text="**Greetings!**") + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_say_unfurl_options(self): + say = AsyncSay(client=self.web_client, channel="C111") + 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") @@ -49,3 +63,17 @@ 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..016549bd6 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_say_stream.py @@ -0,0 +1,104 @@ +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncSayStream: + default_chat_stream_buffer_size = AsyncWebClient.chat_stream.__kwdefaults__["buffer_size"] + + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): + old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + try: + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + yield # run the test here + finally: + cleanup_mock_web_api_server(self) + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_missing_channel_raises(self): + say_stream = AsyncSayStream(client=self.web_client, channel=None, thread_ts="111.222") + with pytest.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", + ) + stream = await say_stream() + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C111", + "thread_ts": "111.222", + "recipient_team_id": "T111", + "recipient_user_id": "U111", + "task_display_mode": None, + } + + @pytest.mark.asyncio + async def test_parameter_overrides(self): + say_stream = AsyncSayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = await say_stream(channel="C222", thread_ts="333.444", recipient_team_id="T222", recipient_user_id="U222") + + assert stream._buffer_size == self.default_chat_stream_buffer_size + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } + + @pytest.mark.asyncio + async def test_buffer_size_overrides(self): + say_stream = AsyncSayStream( + client=self.web_client, + channel="C111", + recipient_team_id="T111", + recipient_user_id="U111", + thread_ts="111.222", + ) + stream = await say_stream( + buffer_size=100, + channel="C222", + thread_ts="333.444", + recipient_team_id="T222", + recipient_user_id="U222", + ) + + assert stream._buffer_size == 100 + assert stream._stream_args == { + "channel": "C222", + "thread_ts": "333.444", + "recipient_team_id": "T222", + "recipient_user_id": "U222", + "task_display_mode": None, + } 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..e785ff89e --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_set_status.py @@ -0,0 +1,47 @@ +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_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..2a09434a8 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py @@ -0,0 +1,48 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server +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_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/test_async_args.py b/tests/slack_bolt_async/kwargs_injection/test_async_args.py index e21e9de6f..f0322f620 100644 --- a/tests/slack_bolt_async/kwargs_injection/test_async_args.py +++ b/tests/slack_bolt_async/kwargs_injection/test_async_args.py @@ -28,6 +28,8 @@ def test_build(self): "ack", "say", "respond", + "complete", + "fail", "next", ] arg_params: dict = build_async_required_kwargs( 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/test_async_custom_listener_matcher.py b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py index 6e226c9b6..96a407aab 100644 --- a/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py +++ b/tests/slack_bolt_async/listener_matcher/test_async_custom_listener_matcher.py @@ -20,7 +20,8 @@ class TestAsyncCustomListenerMatcher: @pytest.mark.asyncio async def test_instantiation(self): matcher: AsyncListenerMatcher = AsyncCustomListenerMatcher( - app_name="foo", func=func, + app_name="foo", + func=func, ) resp = BoltResponse(status=201) diff --git a/tests/slack_bolt_async/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/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..a00b35cd3 --- /dev/null +++ b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py @@ -0,0 +1,79 @@ +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 ( + 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") + + +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_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/test_single_team_authorization.py b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py index 228d45291..0ddb6281d 100644 --- a/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py +++ b/tests/slack_bolt_async/middleware/authorization/test_single_team_authorization.py @@ -1,16 +1,15 @@ -import asyncio - import pytest from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.middleware.authorization.async_single_team_authorization import ( AsyncSingleTeamAuthorization, ) +from slack_bolt.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 ( - 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 @@ -20,27 +19,23 @@ async def next(): class TestSingleTeamAuthorization: - mock_api_server_base_url = "http://localhost:8888" - @pytest.fixture - def event_loop(self): + @pytest.fixture(scope="function", autouse=True) + def setup_teardown(self): old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server_async(self) try: - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + self.mock_api_server_base_url = "http://localhost:8888" + yield # run the test here finally: + cleanup_mock_web_api_server_async(self) restore_os_env(old_os_env) @pytest.mark.asyncio 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" - ) + 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) @@ -48,16 +43,41 @@ async def test_success_pattern(self): assert resp.status == 200 assert resp.body == "" + @pytest.mark.asyncio + async def test_success_pattern_with_bot_scopes(self): + client = AsyncWebClient(base_url=self.mock_api_server_base_url, token="xoxb-valid") + authorization = AsyncSingleTeamAuthorization() + req = AsyncBoltRequest(body="payload={}", headers={}) + req.context["client"] = client + resp = BoltResponse(status=404) + + resp = await authorization.async_process(req=req, resp=resp, next=next) + + assert resp.status == 200 + assert resp.body == "" + assert req.context.authorize_result.bot_scopes == ["chat:write", "commands"] + assert req.context.authorize_result.user_scopes is None + @pytest.mark.asyncio async def test_failure_pattern(self): authorization = AsyncSingleTeamAuthorization() req = AsyncBoltRequest(body="payload={}", headers={}) - req.context["client"] = AsyncWebClient( - base_url=self.mock_api_server_base_url, token="dummy" - ) + 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 == ":x: Please install this app into the workspace :bow:" + 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..c097dd146 --- /dev/null +++ b/tests/slack_bolt_async/middleware/request_verification/test_request_verification.py @@ -0,0 +1,52 @@ +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"}""" diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py index c70e3dfeb..5714e1a6a 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow.py @@ -1,17 +1,18 @@ -import asyncio import json -import re 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, @@ -19,23 +20,27 @@ ) 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, - setup_mock_web_api_server, + cleanup_mock_web_api_server_async, + assert_auth_test_count_async, + setup_mock_web_api_server_async, ) class TestAsyncOAuthFlow: - mock_api_server_base_url = "http://localhost:8888" - @pytest.fixture - def event_loop(self): - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + @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 @@ -47,6 +52,7 @@ async def test_instantiation(self): client_id="111.222", client_secret="xxx", scopes=["chat:write", "commands"], + user_scopes=["search:read"], installation_store=FileInstallationStore(), state_store=FileOAuthStateStore(expiration_seconds=120), ) @@ -56,29 +62,115 @@ async def test_instantiation(self): assert oauth_flow.client is not None @pytest.mark.asyncio - async def test_handle_installation(self): + 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 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None + 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): @@ -124,15 +216,13 @@ async def test_handle_callback(self): 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-signature": [signature_verifier.generate_signature(body=body, timestamp=timestamp)], "x-slack-request-timestamp": [timestamp], } request = AsyncBoltRequest(body=body, headers=headers) response = await app.async_dispatch(request) assert response.status == 200 - assert self.mock_received_requests["/auth.test"] == 1 + await assert_auth_test_count_async(self, 1) @pytest.mark.asyncio async def test_handle_callback_invalid_state(self): @@ -154,6 +244,54 @@ async def test_handle_callback_invalid_state(self): resp = await oauth_flow.handle_callback(req) assert resp.status == 400 + @pytest.mark.asyncio + async def test_handle_callback_invalid_state(self): + class MyOAuthStateStore(AsyncOAuthStateStore): + async def async_issue(self, *args, **kwargs) -> str: + return "expired_one" + + async def async_consume(self, state: str) -> bool: + return False + + oauth_flow = AsyncOAuthFlow( + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=MyOAuthStateStore(), + ) + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state={state}", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 401 + + @pytest.mark.asyncio + async def test_handle_callback_no_state_validation(self): + oauth_flow = AsyncOAuthFlow( + client=AsyncWebClient(base_url=self.mock_api_server_base_url), + settings=AsyncOAuthSettings( + client_id="111.222", + client_secret="xxx", + scopes=["chat:write", "commands"], + installation_store=FileInstallationStore(), + state_validation_enabled=False, # disabled + state_store=None, + ), + ) + state = await oauth_flow.issue_new_state(None) + req = AsyncBoltRequest( + body="", + query=f"code=foo&state=invalid", + headers={"cookie": [f"{oauth_flow.settings.state_cookie_name}={state}"]}, + ) + resp = await oauth_flow.handle_callback(req) + assert resp.status == 200 + @pytest.mark.asyncio async def test_handle_callback_using_options(self): async def success(args: AsyncSuccessArgs) -> BoltResponse: @@ -174,7 +312,8 @@ async def failure(args: AsyncFailureArgs) -> BoltResponse: installation_store=FileInstallationStore(), state_store=FileOAuthStateStore(expiration_seconds=120), callback_options=AsyncCallbackOptions( - success=success, failure=failure, + success=success, + failure=failure, ), ), ) diff --git a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py index 4697f6e2a..00300929f 100644 --- a/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py +++ b/tests/slack_bolt_async/oauth/test_async_oauth_flow_sqlite3.py @@ -1,6 +1,3 @@ -import asyncio -import re - import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -13,21 +10,21 @@ from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - cleanup_mock_web_api_server, - setup_mock_web_api_server, + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, ) class TestAsyncOAuthFlowSQLite3: - mock_api_server_base_url = "http://localhost:8888" - @pytest.fixture - def event_loop(self): - setup_mock_web_api_server(self) - loop = asyncio.get_event_loop() - yield loop - loop.close() - cleanup_mock_web_api_server(self) + @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 @@ -54,17 +51,9 @@ async def test_handle_installation(self): ) req = AsyncBoltRequest(body="") resp = await oauth_flow.handle_installation(req) - assert resp.status == 302 - url = resp.headers["location"][0] - assert ( - re.compile( - "https://slack.com/oauth/v2/authorize\\?state=[-0-9a-z]+." - "&client_id=111\\.222" - "&scope=chat:write,commands" - "&user_scope=" - ).match(url) - is not None - ) + assert resp.status == 200 + assert resp.headers.get("content-type") == ["text/html; charset=utf-8"] + assert "https://slack.com/oauth/v2/authorize?state=" in resp.body @pytest.mark.asyncio async def test_handle_callback(self): @@ -121,7 +110,10 @@ async def failure(args: AsyncFailureArgs) -> BoltResponse: client_id="111.222", client_secret="xxx", scopes=["chat:write", "commands"], - callback_options=AsyncCallbackOptions(success=success, failure=failure,), + callback_options=AsyncCallbackOptions( + success=success, + failure=failure, + ), ) state = await oauth_flow.issue_new_state(None) req = AsyncBoltRequest( 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