Skip to content

Commit

Permalink
feat: listen to block actions (fixes #108, #731)
Browse files Browse the repository at this point in the history
- Add block action payloads for testing
- Add models with validation for block_actions payloads
- Add decorator for block actions
- Register block actions during plugin registration
- Handle block actions events
- Add plugin methods to update and delete messages (Thanks to @pawelros)
- Document block actions

Co-authored-by: Pawel Rosinski <[email protected]>
  • Loading branch information
DonDebonair and pawelros committed May 25, 2024
1 parent 9657817 commit ce5a823
Show file tree
Hide file tree
Showing 55 changed files with 5,002 additions and 440 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ It's really easy!
- Send DMs to any user
- 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)
- 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 @@ -87,7 +88,8 @@ It's really easy!

### Coming Soon

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

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

### ::: machine.plugins.command.Command

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


## Decorators

Expand Down
Binary file added docs/img/block-kit-example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ It's really easy!
- Send DMs to any user
- 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)
- 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 @@ -87,5 +88,6 @@ It's really easy!

### Coming Soon

- Support for Interactive Buttons
- Support for modals
- Support for shortcuts
- ... and much more
2 changes: 1 addition & 1 deletion docs/plugins/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ to do anything from talking to channels, responding to messages, sending DMs, an
## The decorators

Being able to talk in Slack is only half the story for plugins. The functions in your plugin have to be triggered
somehow. Slack Machine provides [decorators](../../api#decorators) for that. You can decorate the functions in your
somehow. Slack Machine provides [decorators](../api.md#decorators) for that. You can decorate the functions in your
plugin class to tell them what they should react to.

As an example, let's create a cool plugin!
Expand Down
103 changes: 103 additions & 0 deletions docs/plugins/block-kit-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Block Kit actions

Slack lets you build interactivity into your Slack app using [**Block Kit**](https://api.slack.com/block-kit). Block
Kit is a UI framework that lets you add interactive elements, such as buttons, input fields, datepickers etc. to
_surfaces_ like messages, modals and the App Home tab.

Slack Machine makes it easy to listen to _actions_ triggered by these interactive elements.

## 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
[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.

Whenever the user interacts with these elements, an event is sent to Slack Machine that contains the `block_id` and
`action_id` corresponding to the block and element in which an action happened.

## Listening to actions

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,
whereas with a regex pattern, you can have the handler fired for multiple matching `block_id`s or `action_id`s. This
is convenient when you want one handler to process multiple actions within a block, for example.

If only `action_id` or `block_id` is provided, the other defaults to `None`, which means it **always matches**.

### Parameters of your action handler

Your block action handler will be called with a [`BlockAction`][machine.plugins.block_action.BlockAction] object that
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
[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
the action that was triggered and the context in which that happened. The
[`user`][machine.plugins.block_action.BlockAction.user] property corresponds to the user that triggered the action
(e.g. clicked a button) and the [`channel`][machine.plugins.block_action.BlockAction.channel] property corresponds
to the channel in which the message was posted where the action was triggered. This property is `None` when the
action happened in a modal or the App Home tab.
The [`triggered_action`][machine.plugins.block_action.BlockAction.triggered_action] field holds information on the
action that triggered the handler, including any value that was the result of the triggered action - such as the
value of the button that was clicked. Lastly, the
[`payload`][machine.plugins.block_action.BlockAction.payload] holds the complete payload the was received by Slack
Machine when the action was triggered. Among other things, it holds the complete _state_ of the interactive blocks
within the message or modal where the action was triggered. This is especially useful when dealing with a _submit_
button that was triggered, where you want to collect all the information in a form for example.

### Example

Let's imagine you're building a plugin for your Slack Machine bot that allows users to vote for what to have for
lunch. You designed the following interaction:

![block-kit-example](../img/block-kit-example.png)

Each lunch option has a vote button. Due to the way Block Kit works, to represent each option like this, they should
be in their own [section](https://api.slack.com/reference/block-kit/blocks#section). Each section will have the
description of the lunch option, the emoji and a button to vote. Sections are blocks, so we want to listen for
actions within different blocks.

This is what the handler could look like:

```python
@action(action_id=None, block_id=re.compile(r"lunch.*", re.IGNORECASE))
async def lunch_action(self, action: BlockAction, logger: BoundLogger):
logger.info("Action triggered", triggered_action=action.triggered_action)
food_block = [block for block in action.payload.message.blocks if block.block_id == action.triggered_action.block_id][0]
food_block_section = cast(blocks.SectionBlock, food_block)
food_description = str(food_block_section.text.text)
msg = f"{action.user.fmt_mention()} has voted for '{food_description}'"
await action.say(msg, ephemeral=False)
```

As you can see, we only care about the `block_id` here and not about the `action_id`. In the blocks that show the
lunch options, `block_id`s would be set like `lunch_ramen`, `lunch_hamburger` etc.

## Responding to an action

As you can see in the example, if you want to send a message to the user after an action was triggered, you can do
so by calling the [`say()`][machine.plugins.block_action.BlockAction.say] method on the _action_ object your handler
received from Slack Machine.
This works just like any other way Slack provides for sending messages. You can include just text, but also rich
content using [_Block Kit_](https://api.slack.com/block-kit)

!!! info

The [`response_url`][machine.plugins.block_action.BlockAction.response_url] property is used by the
[`say()`][machine.plugins.block_action.BlockAction.say] method to send messages to a channel after receiving a
command. It does so by invoking a _Webhook_ using this `response_url` This is different from how
[`message.say()`][machine.plugins.message.Message.say] works - which uses the Slack Web API.

The reason for this is to keep consistency with how Slack recommends interacting with a user. For block actions,
using the `response_url` is the [recommended way](https://api.slack.com/interactivity/handling#message_responses)

!!! warning

The `response_url` is only available when the action was triggered in a message - as opposed to in a modal or
the App Home tab. The reason is of course that in the other two cases there is no channel to send the message to.
8 changes: 5 additions & 3 deletions docs/plugins/interacting.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ two very similar sets of functions are exposed through two classes:
: The [`MachineBasePlugin`][machine.plugins.base.MachineBasePlugin] class every plugin extends, provides methods
to send messages to channels (public, private and DM), using the WebAPI, with support for rich
messages/blocks/attachment. It also supports adding reactions to messages, pinning and unpinning messages, replying
in-thread, sending ephemeral messages to a channel (only visible to 1 user), and much more.
in-thread, sending ephemeral messages to a channel (only visible to 1 user), updating and deleting messages and much
more.

### Message

Expand Down Expand Up @@ -212,7 +213,7 @@ async def broadcast_bathroom_usage(self, msg):
self.emit('bathroom_used', toilet_flushed=True)
```

You can read [the events section][events] to see how your plugin can listen for events.
You can read [the events section][slack-machine-events] to see how your plugin can listen for events.


## Using the Slack Web API in other ways
Expand All @@ -221,4 +222,5 @@ Sometimes you want to use [Slack Web API](https://api.slack.com/web) in ways tha
[`MachineBaserPlugin`][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.
object of the underlying Slack Python SDK. You should be able to call any
[Web API method](https://api.slack.com/methods) with that client.
2 changes: 1 addition & 1 deletion docs/plugins/listening.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Listening for things

Slack Machine allows you to listen for various different things and respond to that. By decorating functions in your
plugin using the [decorators](../../api#decorators) Slack Machine provides, you can tell Slack Machine to run those
plugin using the [decorators](../api.md#decorators) Slack Machine provides, you can tell Slack Machine to run those
functions when something specific happens.

## Listen for a mention
Expand Down
8 changes: 8 additions & 0 deletions machine/clients/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ async def send_scheduled(
channel=channel_id, text=text, post_at=scheduled_ts, **kwargs
)

async def update(self, channel: Channel | str, ts: str, text: str | None, **kwargs: Any) -> AsyncSlackResponse:
channel_id = id_for_channel(channel)
return await self._client.web_client.chat_update(channel=channel_id, ts=ts, text=text, **kwargs)

async def delete(self, channel: Channel | str, ts: str, **kwargs: Any) -> AsyncSlackResponse:
channel_id = id_for_channel(channel)
return await self._client.web_client.chat_delete(channel=channel_id, ts=ts, **kwargs)

async def react(self, channel: Channel | str, ts: str, emoji: str) -> AsyncSlackResponse:
channel_id = id_for_channel(channel)
return await self._client.web_client.reactions_add(name=emoji, channel=channel_id, timestamp=ts)
Expand Down
59 changes: 51 additions & 8 deletions machine/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@
from machine.clients.slack import SlackClient
from machine.handlers import (
create_generic_event_handler,
create_interactive_handler,
create_message_handler,
create_slash_command_handler,
log_request,
)
from machine.models.core import CommandHandler, HumanHelp, Manual, MessageHandler, RegisteredActions
from machine.models.core import (
BlockActionHandler,
CommandHandler,
HumanHelp,
Manual,
MessageHandler,
RegisteredActions,
action_block_id_to_str,
)
from machine.plugins.base import MachineBasePlugin
from machine.plugins.decorators import DecoratedPluginFunc, MatcherConfig, Metadata
from machine.plugins.decorators import ActionConfig, CommandConfig, DecoratedPluginFunc, MatcherConfig, Metadata
from machine.settings import import_settings
from machine.storage import MachineBaseStorage, PluginStorage
from machine.utils.collections import CaseInsensitiveDict
Expand Down Expand Up @@ -215,8 +224,16 @@ def _register_plugin_actions(
class_name=plugin_class_name,
fq_fn_name=fq_fn_name,
function=fn,
command=command_config.command,
is_generator=command_config.is_generator,
command_config=command_config,
class_help=class_help,
)
for block_action_config in metadata.plugin_actions.actions:
self._register_block_action_handler(
class_=cls_instance,
class_name=plugin_class_name,
fq_fn_name=fq_fn_name,
function=fn,
block_action_config=block_action_config,
class_help=class_help,
)

Expand Down Expand Up @@ -259,8 +276,7 @@ def _register_command_handler(
class_name: str,
fq_fn_name: str,
function: Callable[..., Awaitable[None]],
command: str,
is_generator: bool,
command_config: CommandConfig,
class_help: str,
) -> None:
signature = Signature.from_callable(function)
Expand All @@ -270,14 +286,39 @@ def _register_command_handler(
class_name=class_name,
function=function,
function_signature=signature,
command=command,
is_generator=is_generator,
command=command_config.command,
is_generator=command_config.is_generator,
)
command = command_config.command
if command in self._registered_actions.command:
logger.warning("command was already defined, previous handler will be overwritten!", command=command)
self._registered_actions.command[command] = handler
# TODO: add to help

def _register_block_action_handler(
self,
class_: MachineBasePlugin,
class_name: str,
fq_fn_name: str,
function: Callable[..., Awaitable[None]],
block_action_config: ActionConfig,
class_help: str,
) -> None:
signature = Signature.from_callable(function)
logger.debug("signature of block action handler", signature=signature, function=fq_fn_name)
handler = BlockActionHandler(
class_=class_,
class_name=class_name,
function=function,
function_signature=signature,
action_id_matcher=block_action_config.action_id,
block_id_matcher=block_action_config.block_id,
)
action_id = action_block_id_to_str(block_action_config.action_id)
block_id = action_block_id_to_str(block_action_config.block_id)
key = f"{fq_fn_name}-{action_id}-{block_id}"
self._registered_actions.block_actions[key] = handler

@staticmethod
def _parse_human_help(doc: str) -> HumanHelp:
summary = doc.splitlines()[0].split(":")
Expand Down Expand Up @@ -315,10 +356,12 @@ async def run(self) -> None:
)
generic_event_handler = create_generic_event_handler(self._registered_actions)
slash_command_handler = create_slash_command_handler(self._registered_actions, self._client)
block_action_handler = create_interactive_handler(self._registered_actions, self._client)

self._client.register_handler(message_handler)
self._client.register_handler(generic_event_handler)
self._client.register_handler(slash_command_handler)
self._client.register_handler(block_action_handler)
# Establish a WebSocket connection to the Socket Mode servers
await self._socket_mode_client.connect()
logger.info("Connected to Slack")
Expand Down
5 changes: 5 additions & 0 deletions machine/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .command_handler import create_slash_command_handler # noqa
from .event_handler import create_generic_event_handler # noqa
from .interactive_handler import create_interactive_handler # noqa
from .logging import log_request # noqa
from .message_handler import create_message_handler # noqa
63 changes: 63 additions & 0 deletions machine/handlers/command_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

import contextlib
from typing import Any, AsyncGenerator, Awaitable, Callable, Union, cast

from slack_sdk.models import JsonObject
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 structlog.stdlib import get_logger

from machine.clients.slack import SlackClient
from machine.handlers.logging import create_scoped_logger
from machine.models.core import RegisteredActions
from machine.plugins.command import Command

logger = get_logger(__name__)


def create_slash_command_handler(
plugin_actions: RegisteredActions,
slack_client: SlackClient,
) -> Callable[[AsyncBaseSocketModeClient, SocketModeRequest], Awaitable[None]]:
async def handle_slash_command_request(client: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None:
if request.type == "slash_commands":
logger.debug("slash command received", payload=request.payload)
# We only acknowledge request if we know about this command
if request.payload["command"] in plugin_actions.command:
cmd = plugin_actions.command[request.payload["command"]]
command_obj = _gen_command(request.payload, slack_client)
if "logger" in cmd.function_signature.parameters:
command_logger = create_scoped_logger(
cmd.class_name,
cmd.function.__name__,
user_id=command_obj.sender.id,
user_name=command_obj.sender.name,
)
extra_args = {"logger": command_logger}
else:
extra_args = {}
# Check if the handler is a generator. In this case we have an immediate response we can send back
if cmd.is_generator:
gen_fn = cast(Callable[..., AsyncGenerator[Union[dict, JsonObject, str], None]], cmd.function)
logger.debug("Slash command handler is generator, returning immediate ack")
gen = gen_fn(command_obj, **extra_args)
# return immediate reponse
payload = await gen.__anext__()
ack_response = SocketModeResponse(envelope_id=request.envelope_id, payload=payload)
await client.send_socket_mode_response(ack_response)
# Now run the rest of the function
with contextlib.suppress(StopAsyncIteration):
await gen.__anext__()
else:
ack_response = SocketModeResponse(envelope_id=request.envelope_id)
await client.send_socket_mode_response(ack_response)
fn = cast(Callable[..., Awaitable[None]], cmd.function)
await fn(command_obj, **extra_args)

return handle_slash_command_request


def _gen_command(cmd_payload: dict[str, Any], slack_client: SlackClient) -> Command:
return Command(slack_client, cmd_payload)
Loading

0 comments on commit ce5a823

Please sign in to comment.