Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modals #1052

Merged
merged 4 commits into from
Nov 22, 2024
Merged

Modals #1052

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: chartboost/ruff-action@v1
- uses: chartboost/ruff-action@v1
- uses: astral-sh/ruff-action@v1
- uses: astral-sh/ruff-action@v1
with:
args: "format --check"
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repos:
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.7.4
rev: v0.8.0
hooks:
# Run the linter.
- id: ruff
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class DeploymentPlugin(MachineBasePlugin):
**Dropped support for Python 3.8** (v0.38.0)

As of [v0.38.0](https://github.com/DonDebonair/slack-machine/releases/tag/v0.38.0), support for Python 3.8 has been
dropped. Python 3.7 has reached end-of-life on 2024-10-07.
dropped. Python 3.8 has reached end-of-life on 2024-10-07.

## Features

Expand All @@ -59,6 +59,7 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.
- Support for [blocks](https://api.slack.com/reference/block-kit/blocks)
- Support for [message attachments](https://api.slack.com/docs/message-attachments) [Legacy 🏚]
- Support for [interactive elements](https://api.slack.com/block-kit)
- Support for [modals](https://api.slack.com/surfaces/modals)
- Listen and respond to any [Slack event](https://api.slack.com/events) supported by the Events API
- Store and retrieve any kind of data in persistent storage (currently Redis, DynamoDB, SQLite and in-memory storage are
supported)
Expand All @@ -68,7 +69,6 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.

### Coming Soon

- Support for modals
- Support for shortcuts
- ... and much more

Expand Down
6 changes: 6 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ The following classes form the basis for Plugin development.

### ::: machine.plugins.block_action.BlockAction

### ::: machine.plugins.modals.ModalSubmission

### ::: machine.plugins.modals.ModalClosure


## Decorators

Expand All @@ -34,6 +38,8 @@ These classes represent base objects from the Slack API

### ::: machine.models.channel.Channel

### ::: machine.models.interactive.View

## Storage

Storage is exposed to plugins through the `self.storage` field. The following class implements the interface plugins
Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class DeploymentPlugin(MachineBasePlugin):
**Dropped support for Python 3.8** (v0.38.0)

As of [v0.38.0](https://github.com/DonDebonair/slack-machine/releases/tag/v0.38.0), support for Python 3.8 has been
dropped. Python 3.7 has reached end-of-life on 2024-10-07.
dropped. Python 3.8 has reached end-of-life on 2024-10-07.

## Features

Expand All @@ -59,6 +59,7 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.
- Support for [blocks](https://api.slack.com/reference/block-kit/blocks)
- Support for [message attachments](https://api.slack.com/docs/message-attachments) [Legacy 🏚]
- Support for [interactive elements](https://api.slack.com/block-kit)
- Support for [modals](https://api.slack.com/surfaces/modals)
- Listen and respond to any [Slack event](https://api.slack.com/events) supported by the Events API
- Store and retrieve any kind of data in persistent storage (currently Redis, DynamoDB, SQLite, and in-memory storage
are supported)
Expand All @@ -68,6 +69,5 @@ dropped. Python 3.7 has reached end-of-life on 2024-10-07.

### Coming Soon

- Support for modals
- Support for shortcuts
- ... and much more
9 changes: 5 additions & 4 deletions docs/plugins/block-kit-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ Slack Machine makes it easy to listen to _actions_ triggered by these interactiv

## Defining actions

When you're defining [blocks](https://api.slack.com/reference/block-kit ) for your interactive surfaces, each of
these blocks can be given a `block_id`. Within certain blocks, you can place
When you're defining [blocks](https://api.slack.com/reference/block-kit ) for your interactive surfaces - either by
providing a [dict][] or by leveraging the [models of the Slack SDK for Python](https://tools.slack.dev/python-slack-sdk/api-docs/slack_sdk/models/blocks/index.html)
- each of these blocks can be given a `block_id`. Within certain blocks, you can place
[block elements](https://api.slack.com/reference/block-kit/block-elements) that are interactive. These interactive
elements can be given an `action_id`. Given that one block can contain multiple action elements, each `block_id` can
be linked to multiple `action_id`s.
Expand All @@ -19,7 +20,7 @@ Whenever the user interacts with these elements, an event is sent to Slack Machi

## Listening to actions

With the [`action`][machine.plugins.decorators.action] decorator you can define which plugin methods should be
With the [`@action`][machine.plugins.decorators.action] decorator you can define which plugin methods should be
called when a certain action is triggered. The decorator takes 2 arguments: the `block_id` and the `action_id` that
you want to listen to. Both arguments are optional, but **one of them always needs to be set**. Both arguments accept a
[`str`][str] or [`re.Pattern`][re.Pattern]. When a string is provided, the handler only fires upon an exact match,
Expand All @@ -34,7 +35,7 @@ Your block action handler will be called with a [`BlockAction`][machine.plugins.
contains useful information about the action that was triggered and the message or other surface in which the action
was triggered.

You can optionally pass the `logger` argument to get a
You can optionally add the `logger` argument to your handler get a
[logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions)

The [`BlockAction`][machine.plugins.block_action.BlockAction] contains various useful fields and properties about
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins/interacting.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ You can read [the events section][slack-machine-events] to see how your plugin c
## Using the Slack Web API in other ways

Sometimes you want to use [Slack Web API](https://api.slack.com/web) in ways that are not directly exposed by
[`MachineBaserPlugin`][machine.plugins.base.MachineBasePlugin]. In these cases you can use
[`MachineBasePlugin`][machine.plugins.base.MachineBasePlugin]. In these cases you can use
[`self.web_client`][machine.plugins.base.MachineBasePlugin.web_client]. `self.web_client` references the
[`AsyncWebClient`](https://slack.dev/python-slack-sdk/api-docs/slack_sdk/web/async_client.html#slack_sdk.web.async_client.AsyncWebClient)
object of the underlying Slack Python SDK. You should be able to call any
Expand Down
104 changes: 104 additions & 0 deletions docs/plugins/modals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Modals

In Slack, [modals](https://api.slack.com/surfaces/modals) are a way to ask users for input or display information in a
dialog/popup-like form. Modals are a great way to collect information from users, display information, or confirm an
action.

Modals can only be triggered by [actions the user takes](https://api.slack.com/interactivity#user). The most
common types of user actions that can trigger a modal are:

- Shortcuts
- [Slash Commands][slash-commands]
- [Block Kit interative components][block-kit-actions]

For each of these actions, Slack provides a `trigger_id` which can be used to open a modal. **This needs to be done
within 3 seconds of receiving the `trigger_id`**. Slack Machine abstracts most of this away for you and lets you
open modals from Slash Commands and Block Kit actions without having to worry about the `trigger_id`.

## Defining and opening modals

When you want to open a modal, you first need to define the
[_view_](https://api.slack.com/surfaces/modals#composing_views) with the content you want to show. This view has
some additional properties that define how the modal should behave. One important property is the `callback_id` which
is used to identify the modal when it is submitted or closed.

You can define a modal view in 2 ways:

- As a [dict][] that conforms to the [View schema](https://api.slack.com/reference/surfaces/views#modal)
- By constructing a [View](https://tools.slack.dev/python-slack-sdk/api-docs/slack_sdk/models/views/index.html#slack_sdk.models.views.View)
object from the Slack SDK for Python

When you have defined the view, you can open the modal by calling the `open_modal` method on the
[`Command`][machine.plugins.command.Command] object that is passed to your Slash Command handler or on the
[`BlockAction`][machine.plugins.block_action.BlockAction] object that is passed to your Block Kit action handler.

## Listening for modal interactions

Once a modal is opened, the user can interact with the block kit elements within the modal, such as buttons, input
fields, datepickers etc. When the user interacts with these elements, a [block kit action](block-kit-actions.md) can be
triggered which lets you deal with input.

Additionally, when the user _submits_ the modal, this triggers a `view_submission` event that you can listen to with
the [`@modal`][machine.plugins.decorators.modal] decorator. This decorator takes a `callback_id` as
an argument, which is used to identify the modal that was submitted. The `callback_id` can be a [`str`][str] or a
[`re.Pattern`][re.Pattern]. When a string is provided, the handler only fires upon an exact match, whereas with a regex
pattern, you can have the handler fired for multiple matching `callback_id`s. This is convenient when you want one
handler to process multiple modals, for example.

Unless you want to listen for changes to specific input fields - for example to update the modal in-place - it's
probably easiest to use the `@modal` decorator and process the entire input upon modal submission.

### The modal handler function

The handler function will be called with a
[`ModalSubmission`][machine.plugins.modals.ModalSubmission] object that contains useful information about the
modal that was submitted and the user that submitted it. The `ModalSubmission` object has a property
[`view`][machine.plugins.modals.ModalSubmission.view] that contains the complete view that was submitted, including the
state of the input fields of the modal. The object also has a [`user`][machine.plugins.modals.ModalSubmission.user]
property that corresponds to the user that submitted the modal.

You can optionally add the `logger` argument to your handler get a
[logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions)

The `ModalSubmission` contains methods for
[updating the current modal view][machine.plugins.modals.ModalSubmission.update_modal],
[pushing a new view on top of the current one][machine.plugins.modals.ModalSubmission.push_modal] or even
[opening a completely new modal][machine.plugins.modals.ModalSubmission.open_modal].

You can also send a message to the user that submitted the modal with the
[`send_dm`][machine.plugins.modals.ModalSubmission.send_dm] method.

The modal handler function can be defined as a regular `async` function or a generator. When you define it as a
generator, you can use the `yield` statement to:

- [Update the modal view in-place](https://api.slack.com/surfaces/modals#updating_response)
- [Push a new view on top of the current one](https://api.slack.com/surfaces/modals#add_response)
- [Close the current view](https://api.slack.com/surfaces/modals#close_current_view) (which is the default behavior when
nothing is yielded)
- [Close all the views on the stack](https://api.slack.com/surfaces/modals#close_all_views)
- [Display errors in the modal](https://api.slack.com/surfaces/modals#displaying_errors), which is useful when you want
to show validation errors to the user.

!!! warning

You must yield a response to Slack within 3 seconds of receiving the `view_submission` event. If you don't, Slack
will show an error to the user.


## Listening for modal closures

Sometimes you want to know when a user closes a modal without submitting it. This can be useful to clean up
resources or store the state of the modal for later continuation. You can listen for modal closures with the
[`@modal_closed`][machine.plugins.decorators.modal_closed] decorator. This decorator takes a `callback_id` as parameter
and works the same way as the `@modal` decorator.

The handler function will be called with a
[`ModalClosure`][machine.plugins.modals.ModalClosure] object that contains information about the modal that was
closed. Just like the `ModalSubmission` object, the `ModalClosure` object has a
[`view`][machine.plugins.modals.ModalSubmission.view] property that contains the complete view of the modal that was
closed, including the state of the input fields. The object also has a
[`user`][machine.plugins.modals.ModalClosure.user] property that corresponds to the user that submitted the
modal.

You can send a message to the user that submitted the modal with the
[`send_dm`][machine.plugins.modals.ModalClosure.send_dm] method.
16 changes: 6 additions & 10 deletions docs/plugins/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ features:

## Defining your Slash Command in code

The next step is to use the [`command`][machine.plugins.decorators.command] decorator on the function that should be
The next step is to use the [`@command`][machine.plugins.decorators.command] decorator on the function that should be
triggered when the user uses the Slash Command you defined. The decorator takes only 1 parameter: the slash command
that should trigger the decorated function. It should be the same as the Slash Command you just defined in the App
dashboard.
Expand All @@ -55,7 +55,7 @@ information about the slash command invocation. The most important property is p
[`text`][machine.plugins.command.Command.text], which contains any additional text that was passed when the slash
command was used.

You can optionally pass the `logger` argument to get a
You can optionally add the `logger` argument to your handler get a
[logger that was enriched by Slack Machine](misc.md#using-loggers-provided-by-slack-machine-in-your-handler-functions)

### Responding to a command
Expand Down Expand Up @@ -96,14 +96,10 @@ async def hello_again(self, command):
await command.say("This will be sent after the initial acknowledgement")
```

## Other types of responses
## Opening modals

The [`Command`][machine.plugins.command.Command] object that your handler receives, contains an extra piece of
information you can use to trigger more varied reponses: the [`trigger_id`][machine.plugins.command.Command.trigger_id]
The `trigger_id` can used specifically to trigger
[_modal responses_](https://api.slack.com/interactivity/handling#modal_responses). For now, creating a modal is
something you have to take care of yourself. More information on this can be found
[here](https://api.slack.com/surfaces/modals/using).

In future releases, Slack Machine will make working with modals much easier by allowing modals to be opened directly
through the provided command object, and responding to interactions happening in modals through new decorators.
The `trigger_id` can used specifically to trigger [_modal responses_][modals]. You don't have to worry about the
`trigger_id` and instead can just call the [`open_modal`][machine.plugins.command.Command.open_modal] method on the
`Command` object to open a modal.
47 changes: 33 additions & 14 deletions machine/clients/slack.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import annotations

import asyncio
import sys
from collections.abc import AsyncGenerator, Awaitable
from datetime import datetime
from typing import Any, AsyncGenerator, Awaitable, Callable
from typing import Any, Callable
from zoneinfo import ZoneInfo

from slack_sdk.errors import SlackApiError
from slack_sdk.models.views import View
from slack_sdk.socket_mode.aiohttp import SocketModeClient
from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
Expand All @@ -17,11 +19,6 @@
from machine.models import Channel, User
from machine.utils.datetime import calculate_epoch

if sys.version_info >= (3, 9):
from zoneinfo import ZoneInfo # pragma: no cover
else:
from backports.zoneinfo import ZoneInfo # pragma: no cover

logger = get_logger(__name__)


Expand Down Expand Up @@ -293,24 +290,22 @@
channel_id = id_for_channel(channel)
return await self._client.web_client.reactions_add(name=emoji, channel=channel_id, timestamp=ts)

async def open_im(self, user: User | str) -> str:
user_id = id_for_user(user)
response = await self._client.web_client.conversations_open(users=user_id)
async def open_im(self, users: User | str | list[User | str]) -> str:
user_ids = [id_for_user(user) for user in users] if isinstance(users, list) else id_for_user(users)
response = await self._client.web_client.conversations_open(users=user_ids)

Check warning on line 295 in machine/clients/slack.py

View check run for this annotation

Codecov / codecov/patch

machine/clients/slack.py#L294-L295

Added lines #L294 - L295 were not covered by tests
return response["channel"]["id"]

async def send_dm(self, user: User | str, text: str | None, **kwargs: Any) -> AsyncSlackResponse:
user_id = id_for_user(user)
dm_channel_id = await self.open_im(user_id)

return await self._client.web_client.chat_postMessage(channel=dm_channel_id, text=text, as_user=True, **kwargs)
return await self._client.web_client.chat_postMessage(channel=user_id, text=text, as_user=True, **kwargs)

Check warning on line 301 in machine/clients/slack.py

View check run for this annotation

Codecov / codecov/patch

machine/clients/slack.py#L301

Added line #L301 was not covered by tests

async def send_dm_scheduled(self, when: datetime, user: User | str, text: str, **kwargs: Any) -> AsyncSlackResponse:
user_id = id_for_user(user)
dm_channel_id = await self.open_im(user_id)
scheduled_ts = calculate_epoch(when, self._tz)

return await self._client.web_client.chat_scheduleMessage(
channel=dm_channel_id, text=text, as_user=True, post_at=scheduled_ts, **kwargs
channel=user_id, text=text, as_user=True, post_at=scheduled_ts, **kwargs
)

async def pin_message(self, channel: Channel | str, ts: str) -> AsyncSlackResponse:
Expand All @@ -324,3 +319,27 @@
async def set_topic(self, channel: Channel | str, topic: str, **kwargs: Any) -> AsyncSlackResponse:
channel_id = id_for_channel(channel)
return await self._client.web_client.conversations_setTopic(channel=channel_id, topic=topic, **kwargs)

async def open_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse:
return await self._client.web_client.views_open(trigger_id=trigger_id, view=view, **kwargs)

Check warning on line 324 in machine/clients/slack.py

View check run for this annotation

Codecov / codecov/patch

machine/clients/slack.py#L324

Added line #L324 was not covered by tests

async def push_modal(self, trigger_id: str, view: dict | View, **kwargs: Any) -> AsyncSlackResponse:
return await self._client.web_client.views_push(trigger_id=trigger_id, view=view, **kwargs)

Check warning on line 327 in machine/clients/slack.py

View check run for this annotation

Codecov / codecov/patch

machine/clients/slack.py#L327

Added line #L327 was not covered by tests

async def update_modal(
self,
view: dict | View,
view_id: str | None = None,
external_id: str | None = None,
hash: str | None = None,
**kwargs: Any,
) -> AsyncSlackResponse:
return await self._client.web_client.views_update(

Check warning on line 337 in machine/clients/slack.py

View check run for this annotation

Codecov / codecov/patch

machine/clients/slack.py#L337

Added line #L337 was not covered by tests
view=view, view_id=view_id, external_id=external_id, hash=hash, **kwargs
)

async def publish_home_tab(
self, user: User | str, view: dict | View, hash: str | None = None, **kwargs: Any
) -> AsyncSlackResponse:
user_id = id_for_user(user)
return await self._client.web_client.views_publish(user_id=user_id, view=view, hash=hash, **kwargs)

Check warning on line 345 in machine/clients/slack.py

View check run for this annotation

Codecov / codecov/patch

machine/clients/slack.py#L344-L345

Added lines #L344 - L345 were not covered by tests
Loading