From 52a3294a6e620ed744936855af6a1f1ee4c0117a Mon Sep 17 00:00:00 2001 From: Daan Debie Date: Fri, 10 May 2024 19:51:49 +0200 Subject: [PATCH] feat: listen to block actions (fixes #180, #731) - 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 --- README.md | 4 +- docs/api.md | 2 + docs/img/block-kit-example.png | Bin 0 -> 52982 bytes docs/index.md | 4 +- docs/plugins/basics.md | 2 +- docs/plugins/block-kit-actions.md | 103 +++++ docs/plugins/interacting.md | 8 +- docs/plugins/listening.md | 2 +- machine/clients/slack.py | 8 + machine/core.py | 59 ++- machine/handlers/__init__.py | 5 + machine/handlers/command_handler.py | 63 +++ machine/handlers/event_handler.py | 39 ++ machine/handlers/interactive_handler.py | 76 ++++ machine/handlers/logging.py | 24 + .../message_handler.py} | 90 +--- machine/models/core.py | 22 +- machine/models/interactive.py | 429 ++++++++++++++++++ machine/plugins/base.py | 72 +++ machine/plugins/block_action.py | 138 ++++++ machine/plugins/decorators.py | 36 ++ mkdocs.yml | 1 + parse_block_kit.py | 72 +++ poetry.lock | 71 +-- pyproject.toml | 5 +- tests/fake_plugins.py | 8 +- tests/handlers/__init__.py | 0 tests/handlers/conftest.py | 105 +++++ tests/handlers/test_command_handler.py | 46 ++ tests/handlers/test_event_handler.py | 21 + tests/handlers/test_interactive_handler.py | 91 ++++ tests/handlers/test_logging.py | 18 + tests/handlers/test_message_handler.py | 134 ++++++ tests/models/example_payloads/__init__.py | 0 .../example_payloads/block_action_button.py | 116 +++++ .../example_payloads/block_action_button2.py | 116 +++++ .../block_action_button_no_value.py | 148 ++++++ .../block_action_checkboxes.py | 115 +++++ .../block_action_checkboxes2.py | 117 +++++ .../block_action_datepicker.py | 106 +++++ .../block_action_datepicker2.py | 106 +++++ .../example_payloads/block_action_in_modal.py | 170 +++++++ .../block_action_multi_select.py | 155 +++++++ .../block_action_multi_select_channel.py | 252 ++++++++++ .../example_payloads/block_action_overflow.py | 262 +++++++++++ .../block_action_radio_button.py | 268 +++++++++++ .../example_payloads/block_action_select.py | 276 +++++++++++ .../block_action_select_conversation.py | 262 +++++++++++ .../block_action_timepicker.py | 277 +++++++++++ .../block_action_url_input.py | 276 +++++++++++ .../example_payloads/view_submission.py | 182 ++++++++ tests/models/test_block_actions.py | 85 ++++ tests/plugins/test_decorators.py | 61 +++ tests/test_handlers.py | 304 ------------- tests/test_plugin_registration.py | 30 +- 55 files changed, 5002 insertions(+), 440 deletions(-) create mode 100644 docs/img/block-kit-example.png create mode 100644 docs/plugins/block-kit-actions.md create mode 100644 machine/handlers/__init__.py create mode 100644 machine/handlers/command_handler.py create mode 100644 machine/handlers/event_handler.py create mode 100644 machine/handlers/interactive_handler.py create mode 100644 machine/handlers/logging.py rename machine/{handlers.py => handlers/message_handler.py} (54%) create mode 100644 machine/models/interactive.py create mode 100644 machine/plugins/block_action.py create mode 100644 parse_block_kit.py create mode 100644 tests/handlers/__init__.py create mode 100644 tests/handlers/conftest.py create mode 100644 tests/handlers/test_command_handler.py create mode 100644 tests/handlers/test_event_handler.py create mode 100644 tests/handlers/test_interactive_handler.py create mode 100644 tests/handlers/test_logging.py create mode 100644 tests/handlers/test_message_handler.py create mode 100644 tests/models/example_payloads/__init__.py create mode 100644 tests/models/example_payloads/block_action_button.py create mode 100644 tests/models/example_payloads/block_action_button2.py create mode 100644 tests/models/example_payloads/block_action_button_no_value.py create mode 100644 tests/models/example_payloads/block_action_checkboxes.py create mode 100644 tests/models/example_payloads/block_action_checkboxes2.py create mode 100644 tests/models/example_payloads/block_action_datepicker.py create mode 100644 tests/models/example_payloads/block_action_datepicker2.py create mode 100644 tests/models/example_payloads/block_action_in_modal.py create mode 100644 tests/models/example_payloads/block_action_multi_select.py create mode 100644 tests/models/example_payloads/block_action_multi_select_channel.py create mode 100644 tests/models/example_payloads/block_action_overflow.py create mode 100644 tests/models/example_payloads/block_action_radio_button.py create mode 100644 tests/models/example_payloads/block_action_select.py create mode 100644 tests/models/example_payloads/block_action_select_conversation.py create mode 100644 tests/models/example_payloads/block_action_timepicker.py create mode 100644 tests/models/example_payloads/block_action_url_input.py create mode 100644 tests/models/example_payloads/view_submission.py create mode 100644 tests/models/test_block_actions.py delete mode 100644 tests/test_handlers.py diff --git a/README.md b/README.md index 6ed4ab9d..c808a408 100644 --- a/README.md +++ b/README.md @@ -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) @@ -87,7 +88,8 @@ It's really easy! ### Coming Soon -- Support for Interactive Buttons +- Support for modals +- Support for shortcuts - ... and much more ## Installation diff --git a/docs/api.md b/docs/api.md index ccb33664..286fe646 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,6 +16,8 @@ The following classes form the basis for Plugin development. ### ::: machine.plugins.command.Command +### ::: machine.plugins.block_action.BlockAction + ## Decorators diff --git a/docs/img/block-kit-example.png b/docs/img/block-kit-example.png new file mode 100644 index 0000000000000000000000000000000000000000..ceccd3de3cdb466b3799cffd9774138a18cb6938 GIT binary patch literal 52982 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 n=6d-oh4dO||Nr~1{hwcxaOp9B663LoA9 literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 950b16d4..3f7689a8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) @@ -87,5 +88,6 @@ It's really easy! ### Coming Soon -- Support for Interactive Buttons +- Support for modals +- Support for shortcuts - ... and much more diff --git a/docs/plugins/basics.md b/docs/plugins/basics.md index 81a1d912..50ce254c 100644 --- a/docs/plugins/basics.md +++ b/docs/plugins/basics.md @@ -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! diff --git a/docs/plugins/block-kit-actions.md b/docs/plugins/block-kit-actions.md new file mode 100644 index 00000000..a9bc26e5 --- /dev/null +++ b/docs/plugins/block-kit-actions.md @@ -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. diff --git a/docs/plugins/interacting.md b/docs/plugins/interacting.md index 04d4ad4b..6690560d 100644 --- a/docs/plugins/interacting.md +++ b/docs/plugins/interacting.md @@ -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 @@ -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 @@ -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. diff --git a/docs/plugins/listening.md b/docs/plugins/listening.md index 7f389ed7..12de192a 100644 --- a/docs/plugins/listening.md +++ b/docs/plugins/listening.md @@ -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 diff --git a/machine/clients/slack.py b/machine/clients/slack.py index 003004a4..373c30d4 100644 --- a/machine/clients/slack.py +++ b/machine/clients/slack.py @@ -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) diff --git a/machine/core.py b/machine/core.py index 6f4850c5..17a96532 100644 --- a/machine/core.py +++ b/machine/core.py @@ -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 @@ -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, ) @@ -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) @@ -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(":") @@ -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") diff --git a/machine/handlers/__init__.py b/machine/handlers/__init__.py new file mode 100644 index 00000000..974fd3fa --- /dev/null +++ b/machine/handlers/__init__.py @@ -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 diff --git a/machine/handlers/command_handler.py b/machine/handlers/command_handler.py new file mode 100644 index 00000000..f6ca86a9 --- /dev/null +++ b/machine/handlers/command_handler.py @@ -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) diff --git a/machine/handlers/event_handler.py b/machine/handlers/event_handler.py new file mode 100644 index 00000000..1f125670 --- /dev/null +++ b/machine/handlers/event_handler.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable + +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.models.core import RegisteredActions + +logger = get_logger(__name__) + + +def create_generic_event_handler( + plugin_actions: RegisteredActions, +) -> Callable[[AsyncBaseSocketModeClient, SocketModeRequest], Awaitable[None]]: + async def handle_event_request(client: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None: + if request.type == "events_api": + # Acknowledge the request anyway + response = SocketModeResponse(envelope_id=request.envelope_id) + # Don't forget having await for method calls + await client.send_socket_mode_response(response) + + # only process message events + if request.payload["event"]["type"] in plugin_actions.process: + await dispatch_event_handlers( + request.payload["event"], list(plugin_actions.process[request.payload["event"]["type"]].values()) + ) + + return handle_event_request + + +async def dispatch_event_handlers( + event: dict[str, Any], event_handlers: list[Callable[[dict[str, Any]], Awaitable[None]]] +) -> None: + handler_funcs = [f(event) for f in event_handlers] + await asyncio.gather(*handler_funcs) diff --git a/machine/handlers/interactive_handler.py b/machine/handlers/interactive_handler.py new file mode 100644 index 00000000..bfd8f4f9 --- /dev/null +++ b/machine/handlers/interactive_handler.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import asyncio +import re +from typing import Awaitable, Callable, Union + +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.models.interactive import Action, BlockActionsPayload, InteractivePayload +from machine.plugins.block_action import BlockAction + +logger = get_logger(__name__) + + +def create_interactive_handler( + plugin_actions: RegisteredActions, + slack_client: SlackClient, +) -> Callable[[AsyncBaseSocketModeClient, SocketModeRequest], Awaitable[None]]: + async def handle_interactive_request(client: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None: + if request.type == "interactive": + logger.debug("interactive trigger received", payload=request.payload) + # Acknowledge the request anyway + response = SocketModeResponse(envelope_id=request.envelope_id) + # Don't forget having await for method calls + await client.send_socket_mode_response(response) + parsed_payload = InteractivePayload.validate_python(request.payload) + if parsed_payload.type == "block_actions": + await handle_block_actions(parsed_payload, plugin_actions, slack_client) + + return handle_interactive_request + + +async def handle_block_actions( + payload: BlockActionsPayload, + plugin_actions: RegisteredActions, + slack_client: SlackClient, +) -> None: + handler_funcs = [] + for handler in plugin_actions.block_actions.values(): + # if neither block_id matcher nor action_id matcher is present, we consider it as no match + # but this is asserted during the registration of the handler + for action in payload.actions: + if _matches(handler.block_id_matcher, action.block_id) and _matches( + handler.action_id_matcher, action.action_id + ): + block_action_obj = _gen_block_action(payload, action, slack_client) + if "logger" in handler.function_signature.parameters: + block_action_logger = create_scoped_logger( + handler.class_name, + handler.function.__name__, + user_id=block_action_obj.user.id, + user_name=block_action_obj.user.name, + ) + extra_args = {"logger": block_action_logger} + else: + extra_args = {} + handler_funcs.append(handler.function(block_action_obj, **extra_args)) + await asyncio.gather(*handler_funcs) + + +def _matches(matcher: Union[re.Pattern[str], str, None], input_: str) -> bool: + if matcher is None: + return True + if isinstance(matcher, re.Pattern): + return matcher.match(input_) is not None + return matcher == input_ + + +def _gen_block_action(payload: BlockActionsPayload, triggered_action: Action, slack_client: SlackClient) -> BlockAction: + return BlockAction(slack_client, payload, triggered_action) diff --git a/machine/handlers/logging.py b/machine/handlers/logging.py new file mode 100644 index 00000000..23d3e290 --- /dev/null +++ b/machine/handlers/logging.py @@ -0,0 +1,24 @@ +from typing import Any + +from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from structlog.stdlib import BoundLogger, get_logger + +logger = get_logger(__name__) + + +async def log_request(_: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None: + logger.debug("Request received", type=request.type, request=request.to_dict()) + + +def create_scoped_logger(class_name: str, function_name: str, **kwargs: Any) -> BoundLogger: + """ + Create a scope logger for a plugin handler + :param class_name: The name of the class that contains the handler + :param function_name: The name of the handler function + :param kwargs: Additional context to bind to the logger + """ + fq_fn_name = f"{class_name}.{function_name}" + handler_logger = get_logger(fq_fn_name) + handler_logger = handler_logger.bind(**kwargs) + return handler_logger diff --git a/machine/handlers.py b/machine/handlers/message_handler.py similarity index 54% rename from machine/handlers.py rename to machine/handlers/message_handler.py index 0b6ea642..de1535ae 100644 --- a/machine/handlers.py +++ b/machine/handlers/message_handler.py @@ -1,19 +1,17 @@ from __future__ import annotations import asyncio -import contextlib import re -from typing import Any, AsyncGenerator, Awaitable, Callable, Mapping, Union, cast +from typing import Any, Awaitable, Callable, Mapping -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 BoundLogger, get_logger +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 MessageHandler, RegisteredActions -from machine.plugins.command import Command from machine.plugins.message import Message logger = get_logger(__name__) @@ -50,68 +48,6 @@ async def handle_message_request(client: AsyncBaseSocketModeClient, request: Soc return handle_message_request -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__, command_obj.sender.id, 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 create_generic_event_handler( - plugin_actions: RegisteredActions, -) -> Callable[[AsyncBaseSocketModeClient, SocketModeRequest], Awaitable[None]]: - async def handle_event_request(client: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None: - if request.type == "events_api": - # Acknowledge the request anyway - response = SocketModeResponse(envelope_id=request.envelope_id) - # Don't forget having await for method calls - await client.send_socket_mode_response(response) - - # only process message events - if request.payload["event"]["type"] in plugin_actions.process: - await dispatch_event_handlers( - request.payload["event"], list(plugin_actions.process[request.payload["event"]["type"]].values()) - ) - - return handle_event_request - - -async def log_request(_: AsyncBaseSocketModeClient, request: SocketModeRequest) -> None: - logger.debug("Request received", type=request.type, request=request.to_dict()) - - def generate_message_matcher(settings: Mapping) -> re.Pattern[str]: alias_regex = "" if "ALIASES" in settings: @@ -192,10 +128,6 @@ def _gen_message(event: dict[str, Any], slack_client: SlackClient) -> Message: return Message(slack_client, event) -def _gen_command(cmd_payload: dict[str, Any], slack_client: SlackClient) -> Command: - return Command(slack_client, cmd_payload) - - async def dispatch_listeners( event: dict[str, Any], message_handlers: list[MessageHandler], slack_client: SlackClient, log_handled_message: bool ) -> None: @@ -209,7 +141,7 @@ async def dispatch_listeners( message = _gen_message(event, slack_client) extra_params = {**match.groupdict()} handler_logger = create_scoped_logger( - handler.class_name, handler.function.__name__, message.sender.id, message.sender.name + handler.class_name, handler.function.__name__, user_id=message.sender.id, user_name=message.sender.name ) if log_handled_message: handler_logger.info("Handling message", message=message.text) @@ -218,17 +150,3 @@ async def dispatch_listeners( handler_funcs.append(handler.function(message, **extra_params)) await asyncio.gather(*handler_funcs) return - - -async def dispatch_event_handlers( - event: dict[str, Any], event_handlers: list[Callable[[dict[str, Any]], Awaitable[None]]] -) -> None: - handler_funcs = [f(event) for f in event_handlers] - await asyncio.gather(*handler_funcs) - - -def create_scoped_logger(class_name: str, function_name: str, user_id: str, user_name: str) -> BoundLogger: - fq_fn_name = f"{class_name}.{function_name}" - handler_logger = get_logger(fq_fn_name) - handler_logger = handler_logger.bind(user_id=user_id, user_name=user_name) - return handler_logger diff --git a/machine/models/core.py b/machine/models/core.py index ad20a606..7793a78a 100644 --- a/machine/models/core.py +++ b/machine/models/core.py @@ -3,7 +3,7 @@ import re from dataclasses import dataclass, field from inspect import Signature -from typing import Any, AsyncGenerator, Awaitable, Callable +from typing import Any, AsyncGenerator, Awaitable, Callable, Union from slack_sdk.models import JsonObject @@ -42,9 +42,29 @@ class CommandHandler: is_generator: bool +@dataclass +class BlockActionHandler: + class_: MachineBasePlugin + class_name: str + function: Callable[..., Awaitable[None]] + function_signature: Signature + action_id_matcher: Union[re.Pattern[str], str, None] + block_id_matcher: Union[re.Pattern[str], str, None] + + @dataclass class RegisteredActions: listen_to: dict[str, MessageHandler] = field(default_factory=dict) respond_to: dict[str, MessageHandler] = field(default_factory=dict) process: dict[str, dict[str, Callable[[dict[str, Any]], Awaitable[None]]]] = field(default_factory=dict) command: dict[str, CommandHandler] = field(default_factory=dict) + block_actions: dict[str, BlockActionHandler] = field(default_factory=dict) + + +def action_block_id_to_str(id_: Union[str, re.Pattern[str], None]) -> str: + if id_ is None: + return "*" + elif isinstance(id_, str): + return id_ + else: + return id_.pattern diff --git a/machine/models/interactive.py b/machine/models/interactive.py new file mode 100644 index 00000000..9bcce507 --- /dev/null +++ b/machine/models/interactive.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +from datetime import date, time +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, Field, TypeAdapter +from pydantic.functional_validators import PlainValidator, model_validator +from pydantic_core.core_schema import ValidationInfo +from slack_sdk.models.blocks import Block as SlackSDKBlock +from typing_extensions import Annotated + + +class TypedModel(BaseModel): + type: str + + +class User(BaseModel): + id: str + username: str + name: str + team_id: str + + +class Team(BaseModel): + id: str + domain: str + + +class Channel(BaseModel): + id: str + name: str + + +class MessageContainer(TypedModel): + type: Literal["message"] + message_ts: str + channel_id: str + is_ephemeral: bool + + +class MessageAttachmentContainer(TypedModel): + type: Literal["message_attachment"] + message_ts: str + attachment_id: int + channel_id: str + is_ephemeral: bool + is_app_unfurl: bool + + +class ViewContainer(TypedModel): + type: Literal["view"] + view_id: str + + +Container = Annotated[Union[MessageContainer, MessageAttachmentContainer, ViewContainer], Field(discriminator="type")] + + +class PlainText(TypedModel): + type: Literal["plain_text"] + text: str + emoji: bool + + +class MarkdownText(TypedModel): + type: Literal["mrkdwn"] + text: str + verbatim: bool + + +Text = Annotated[Union[PlainText, MarkdownText], Field(discriminator="type")] + + +class Option(BaseModel): + text: Text + value: str + + +class CheckboxValues(TypedModel): + type: Literal["checkboxes"] + selected_options: List[Option] + + +class DatepickerValue(TypedModel): + type: Literal["datepicker"] + selected_date: Optional[date] + + +class EmailValue(TypedModel): + type: Literal["email_text_input"] + value: Optional[str] = None + + +class StaticSelectValue(TypedModel): + type: Literal["static_select"] + selected_option: Optional[Option] + + +class ChannelSelectValue(TypedModel): + type: Literal["channels_select"] + selected_channel: Optional[str] + + +class ConversationSelectValue(TypedModel): + type: Literal["conversations_select"] + selected_conversation: Optional[str] + + +class UserSelectValue(TypedModel): + type: Literal["users_select"] + selected_user: Optional[str] + + +class ExternalSelectValue(TypedModel): + type: Literal["external_select"] + selected_option: Optional[str] + + +class MultiStaticSelectValues(TypedModel): + type: Literal["multi_static_select"] + selected_options: List[Option] + + +class MultiChannelSelectValues(TypedModel): + type: Literal["multi_channels_select"] + selected_channels: List[str] + + +class MultiConversationSelectValues(TypedModel): + type: Literal["multi_conversations_select"] + selected_conversations: List[str] + + +class MultiUserSelectValues(TypedModel): + type: Literal["multi_users_select"] + selected_users: List[str] + + +class MultiExternalSelectValues(TypedModel): + type: Literal["multi_external_select"] + selected_options: List[str] + + +class NumberValue(TypedModel): + type: Literal["number_input"] + value: Union[float, int, None] = None + + +class PlainTextInputValue(BaseModel): + type: Literal["plain_text_input"] + value: Optional[str] + + +class RichTextInputValue(TypedModel): + type: Literal["rich_text_input"] + value: Optional[str] + + +class RadioValues(TypedModel): + type: Literal["radio_buttons"] + selected_option: Optional[Option] + + +class TimepickerValue(TypedModel): + type: Literal["timepicker"] + selected_time: Optional[time] + + +class UrlValue(TypedModel): + type: Literal["url_text_input"] + value: Optional[str] = None + + +Values = Annotated[ + Union[ + CheckboxValues, + DatepickerValue, + EmailValue, + StaticSelectValue, + ChannelSelectValue, + ConversationSelectValue, + UserSelectValue, + ExternalSelectValue, + MultiStaticSelectValues, + MultiChannelSelectValues, + MultiConversationSelectValues, + MultiUserSelectValues, + MultiExternalSelectValues, + NumberValue, + PlainTextInputValue, + RichTextInputValue, + RadioValues, + TimepickerValue, + UrlValue, + ], + Field(discriminator="type"), +] + + +class State(BaseModel): + values: Dict[str, Dict[str, Values]] + + +class BaseAction(TypedModel): + action_id: str + block_id: str + type: str + action_ts: str + + +class RadioButtonsAction(BaseAction): + type: Literal["radio_buttons"] + selected_option: Option + + +class ButtonAction(BaseAction): + type: Literal["button"] + text: Text + value: Optional[str] = None + style: Optional[str] = None + + +class CheckboxAction(BaseAction): + type: Literal["checkboxes"] + selected_options: List[Option] + + +class DatepickerAction(BaseAction): + type: Literal["datepicker"] + selected_date: Optional[date] + + +class StaticSelectAction(BaseAction): + type: Literal["static_select"] + selected_option: Option + + +class ChannelSelectAction(BaseAction): + type: Literal["channels_select"] + selected_channel: str + + +class ConversationSelectAction(BaseAction): + type: Literal["conversations_select"] + selected_conversation: str + + +class UserSelectAction(BaseAction): + type: Literal["users_select"] + selected_user: str + + +class ExternalSelectAction(BaseAction): + type: Literal["external_select"] + selected_option: str + + +class MultiStaticSelectAction(BaseAction): + type: Literal["multi_static_select"] + selected_options: List[Option] + + +class MultiChannelSelectAction(BaseAction): + type: Literal["multi_channels_select"] + selected_channels: List[str] + + +class MultiConversationSelectAction(BaseAction): + type: Literal["multi_conversations_select"] + selected_conversations: List[str] + + +class MultiUserSelectAction(BaseAction): + type: Literal["multi_users_select"] + selected_users: List[str] + + +class MultiExternalSelectAction(BaseAction): + type: Literal["multi_external_select"] + selected_options: List[str] + + +class TimepickerAction(BaseAction): + type: Literal["timepicker"] + selected_time: time + + +class UrlAction(BaseAction): + type: Literal["url_text_input"] + value: str + + +class OverflowAction(BaseAction): + type: Literal["overflow"] + selected_option: Option + + +class PlainTextInputAction(BaseAction): + type: Literal["plain_text_input"] + value: str + + +class RichTextInputAction(BaseAction): + type: Literal["rich_text_input"] + value: str + + +Action = Annotated[ + Union[ + RadioButtonsAction, + ButtonAction, + CheckboxAction, + DatepickerAction, + StaticSelectAction, + ChannelSelectAction, + ConversationSelectAction, + UserSelectAction, + ExternalSelectAction, + MultiStaticSelectAction, + MultiChannelSelectAction, + MultiConversationSelectAction, + MultiUserSelectAction, + MultiExternalSelectAction, + TimepickerAction, + UrlAction, + OverflowAction, + PlainTextInputAction, + RichTextInputAction, + ], + Field(discriminator="type"), +] + + +def validate_block(block: Any, info: ValidationInfo) -> SlackSDKBlock: + block = SlackSDKBlock.parse(block) + if block is None: + raise ValueError("Block was not recognized!") + return block + + +Block = Annotated[SlackSDKBlock, PlainValidator(validate_block)] + + +class Message(BaseModel): + user: str + type: str + ts: str + bot_id: str + app_id: str + text: str + team: str + blocks: List[Block] + + +class View(BaseModel): + id: str + team_id: str + type: Literal["modal", "home"] + blocks: List[Block] + private_metadata: str + callback_id: str + state: State + hash: str + title: Text + clear_on_close: bool + notify_on_close: bool + close: Optional[Text] + submit: Optional[Text] + previous_view_id: Optional[str] + root_view_id: str + app_id: str + external_id: str + app_installed_team_id: str + bot_id: str + + +class ResponseUrlForView(BaseModel): + block_id: str + action_id: str + channel_id: str + response_url: str + + +class BlockActionsPayload(TypedModel): + type: Literal["block_actions"] + user: User + api_app_id: str + token: str + container: Container + trigger_id: str + team: Team + enterprise: Optional[str] + is_enterprise_install: bool + channel: Optional[Channel] = None + message: Optional[Message] = None + view: Optional[View] = None + state: Optional[State] = None + response_url: Optional[str] = None + actions: List[Action] + + @model_validator(mode="after") + def validate_view_or_message(self) -> BlockActionsPayload: + if self.view is None and self.message is None: + raise ValueError("Either view or message must be present!") + if self.message is not None: + if self.channel is None: + raise ValueError("channel must be present when message is present!") + if self.state is None: + raise ValueError("state must be present when message is present!") + if self.response_url is None: + raise ValueError("response_url must be present when message is present!") + return self + + +class ViewSubmissionPayload(TypedModel): + type: Literal["view_submission"] + team: Team + user: User + view: View + enterprise: Optional[str] + api_app_id: str + token: str + trigger_id: str + response_urls: List[ResponseUrlForView] + is_enterprise_install: bool + + +InteractivePayload = TypeAdapter( + Annotated[Union[BlockActionsPayload, ViewSubmissionPayload], Field(discriminator="type")] +) diff --git a/machine/plugins/base.py b/machine/plugins/base.py index feb3b594..5c67c06c 100644 --- a/machine/plugins/base.py +++ b/machine/plugins/base.py @@ -239,6 +239,78 @@ async def say_scheduled( **kwargs, ) + async def update( + self, + channel: Channel | str, + ts: str, + text: str | None = None, + attachments: Sequence[Attachment] | Sequence[dict[str, Any]] | None = None, + blocks: Sequence[Block] | Sequence[dict[str, Any]] | None = None, + ephemeral_user: User | str | None = None, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Update an existing message + + Update an existing message using the WebAPI. Allows for rich formatting using + [blocks] and/or [attachments]. You can provide blocks and attachments as Python dicts or + you can use the [convenient classes] that the underlying slack client provides. + Can also update in-thread and ephemeral messages, visible to only one user. + Any extra kwargs you provide, will be passed on directly to the [`chat.update`][chat_update] request. + + [attachments]: https://api.slack.com/docs/message-attachments + [blocks]: https://api.slack.com/reference/block-kit/blocks + [convenient classes]: https://github.com/slackapi/python-slack-sdk/tree/main/slack/web/classes + [chat_update]: https://api.slack.com/methods/chat.update + + :param channel: [`Channel`][machine.models.channel.Channel] object or id of channel to send + message to. Can be public or private (group) channel, or DM channel. + :param ts: timestamp of the message to be updated. + :param text: message text + :param attachments: optional attachments (see [attachments]) + :param blocks: optional blocks (see [blocks]) + :param thread_ts: optional timestamp of thread, to send a message in that thread + :param ephemeral_user: optional user name or id if the message needs to visible + to a specific user only + :return: Dictionary deserialized from [`chat.update`](https://api.slack.com/methods/chat.update) request + + + """ + return await self._client.update( + channel, + ts=ts, + text=text, + attachments=attachments, + blocks=blocks, + ephemeral_user=ephemeral_user, + **kwargs, + ) + + async def delete( + self, + channel: Channel | str, + ts: str, + **kwargs: Any, + ) -> AsyncSlackResponse: + """Delete an existing message + + Delete an existing message using the WebAPI. + Any extra kwargs you provide, will be passed on directly to the [`chat.delete`][chat_delete] request. + + [chat_delete]: https://api.slack.com/methods/chat.delete + + :param channel: [`Channel`][machine.models.channel.Channel] object or id of channel to send + message to. Can be public or private (group) channel, or DM channel. + :param ts: timestamp of the message to be deleted. + :return: Dictionary deserialized from [`chat.delete`](https://api.slack.com/methods/chat.delete) request + + + """ + return await self._client.delete( + channel, + ts=ts, + **kwargs, + ) + async def react(self, channel: Channel | str, ts: str, emoji: str) -> AsyncSlackResponse: """React to a message in a channel diff --git a/machine/plugins/block_action.py b/machine/plugins/block_action.py new file mode 100644 index 00000000..59db9244 --- /dev/null +++ b/machine/plugins/block_action.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import Any, Optional, Sequence, Union + +from slack_sdk.models.attachments import Attachment +from slack_sdk.models.blocks import Block +from slack_sdk.webhook import WebhookResponse +from slack_sdk.webhook.async_client import AsyncWebhookClient +from structlog.stdlib import get_logger + +from machine.clients.slack import SlackClient +from machine.models import Channel, User +from machine.models.interactive import Action, BlockActionsPayload, State + +logger = get_logger(__name__) + + +class BlockAction: + """A Slack block action that was received by the bot + + This class represents a block action that was received by the bot and passed to a plugin. + Block actions are actions that are triggered by interactions with blocks in Slack messages and modals. + This class contains metadata about the block action, such as the action that happened that triggered this handler, + the user that triggered the action, the state of the block when the action was triggered, the payload that was + received when the action was triggered. + """ + + payload: BlockActionsPayload + triggered_action: Action + + def __init__(self, client: SlackClient, payload: BlockActionsPayload, triggered_action: Action): + self._client = client + self.payload = payload #: blablab + """The payload that was received by the bot when the action was triggered that this plugin method listens for""" + self.triggered_action = triggered_action + """The action that triggered this plugin method""" + self._webhook_client = AsyncWebhookClient(self.payload.response_url) if self.payload.response_url else None + + @property + def user(self) -> User: + """The user that triggered the action + + :return: the user that triggered the action + """ + return self._client.users[self.payload.user.id] + + @property + def channel(self) -> Optional[Channel]: + """The channel the action was triggered in + + :return: the channel the action was triggered in or None if the action was triggered in a modal + """ + if self.payload.channel is None: + return None + return self._client.channels[self.payload.channel.id] + + @property + def state(self) -> Optional[State]: + """The state of the block when the action was triggered + + :return: the state of the block when the action was triggered + """ + return self.payload.state + + @property + def response_url(self) -> Optional[str]: + """The response URL for the action + + :return: the response URL for the action or None if the action was triggered in a modal + """ + return self.payload.response_url + + @property + def trigger_id(self) -> str: + """The trigger id associated with the action + + The trigger id can be user ot open a modal + + :return: the trigger id for the action + """ + return self.payload.trigger_id + + async def say( + self, + text: Optional[str] = None, + attachments: Union[Sequence[Attachment], Sequence[dict[str, Any]], None] = None, + blocks: Union[Sequence[Block], Sequence[dict[str, Any]], None] = None, + ephemeral: bool = True, + replace_original: bool = False, + delete_original: bool = False, + **kwargs: Any, + ) -> Optional[WebhookResponse]: + """Send a new message to the channel the block action was triggered in + + Send a new message to the channel the block action was triggered in, using the response_url as a webhook. + If the block action happened in a modal, the response_url will be None and this method will not send a message + but instead log a warning. + Allows for rich formatting using [blocks] and/or [attachments] . You can provide blocks + and attachments as Python dicts or you can use the [convenient classes] that the + underlying slack client provides. + This will send an ephemeral message by default, only visible to the user that triggered the action. + You can set `ephemeral` to `False` to make the message visible to everyone in the channel. + By default, Slack replaces the original message in which the action was triggered. This method overrides this + behavior. If you want your message to replace the original, set replace_original to True. + Any extra kwargs you provide, will be passed on directly to `AsyncWebhookClient.send()` + + [attachments]: https://api.slack.com/docs/message-attachments + [blocks]: https://api.slack.com/reference/block-kit/blocks + [convenient classes]: https://github.com/slackapi/python-slack-sdk/tree/main/slack/web/classes + + :param text: message text + :param attachments: optional attachments (see [attachments]) + :param blocks: optional blocks (see [blocks]) + :param ephemeral: `True/False` wether to send the message as an ephemeral message, only + visible to the user that initiated the action + :param replace_original: `True/False` whether the message that contains the block from which the action was + triggered should be replaced by this message + :param delete_original: `True/False` whether the message that contains the block from which the action was + triggered should be deleted. No other parameters should be provided. + :return: Dictionary deserialized from `AsyncWebhookClient.send()` + + """ + if self._webhook_client is None: + logger.warning( + "response_url is None, cannot send message. This likely means the action was triggered in a modal." + ) + return None + + response_type = "ephemeral" if ephemeral else "in_channel" + return await self._webhook_client.send( + text=text, + attachments=attachments, + blocks=blocks, + response_type=response_type, + replace_original=replace_original, + delete_original=delete_original, + **kwargs, + ) diff --git a/machine/plugins/decorators.py b/machine/plugins/decorators.py index 64f2f59f..379240ad 100644 --- a/machine/plugins/decorators.py +++ b/machine/plugins/decorators.py @@ -29,6 +29,12 @@ class CommandConfig: is_generator: bool = False +@dataclass +class ActionConfig: + action_id: Union[re.Pattern[str], str, None] = None + block_id: Union[re.Pattern[str], str, None] = None + + @dataclass class PluginActions: process: list[str] = field(default_factory=list) @@ -36,6 +42,7 @@ class PluginActions: respond_to: list[MatcherConfig] = field(default_factory=list) schedule: dict[str, Any] | None = None commands: list[CommandConfig] = field(default_factory=list) + actions: list[ActionConfig] = field(default_factory=list) @dataclass @@ -152,6 +159,35 @@ def command_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]: return command_decorator +def action( + action_id: Union[re.Pattern[str], str, None] = None, block_id: Union[re.Pattern[str], str, None] = None +) -> Callable[[Callable[P, R]], DecoratedPluginFunc[P, R]]: + """Respond to block actions + + This decorator will enable a Plugin method to be triggered when certain block actions are + received. The Plugin method will be called when a block action event is received for which + the action_id and block_id match the provided values. action_id and block_id can be strings, + in which case the incoming action_id and block_id must match exactly, or regex patterns, in + which case the incoming action_id and block_id must match the regex pattern. + + Both action_id and block_id are optional, but **at least one of them must be provided**. + + :param action_id: the action_id to respond to, can be a string or regex pattern + :param block_id: the block_id to respond to, can be a string or regex pattern + :return: wrapped method + """ + + def action_decorator(f: Callable[P, R]) -> DecoratedPluginFunc[P, R]: + fn = cast(DecoratedPluginFunc, f) + fn.metadata = getattr(f, "metadata", Metadata()) + if action_id is None and block_id is None: + raise ValueError("At least one of action_id or block_id must be provided") + fn.metadata.plugin_actions.actions.append(ActionConfig(action_id=action_id, block_id=block_id)) + return fn + + return action_decorator + + def schedule( year: int | str | None = None, month: int | str | None = None, diff --git a/mkdocs.yml b/mkdocs.yml index 7d508c7d..815f4b8f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ nav: - 'plugins/listening.md' - 'plugins/slash-commands.md' - 'plugins/interacting.md' + - 'plugins/block-kit-actions.md' - 'plugins/settings.md' - 'plugins/storage.md' - 'plugins/misc.md' diff --git a/parse_block_kit.py b/parse_block_kit.py new file mode 100644 index 00000000..845fa3ec --- /dev/null +++ b/parse_block_kit.py @@ -0,0 +1,72 @@ +import copy +import json +import sys +from types import MethodType +from typing import Optional, Union + +from slack_sdk.models import JsonObject +from slack_sdk.models.blocks import ( + Block, + BlockElement, + MarkdownTextObject, + Option, + PlainTextObject, + TextObject, +) +from slack_sdk.models.views import View + + +def repr_override(self): + values_dict = self.__dict__ + if values_dict: + non_null_values_dict = {k: v for k, v in values_dict.items() if v is not None} + only_public_values_dict = {k: v for k, v in non_null_values_dict.items() if not k.startswith("_")} + type_removed_dict = {k: v for k, v in only_public_values_dict.items() if k != "type"} + args_string = ", ".join([f"{k}={repr(v)}" for k, v in type_removed_dict.items()]) + return f"{self.__class__.__name__}({args_string})" + else: + return self.__str__() + + +def parse(cls, block_element: Union[dict, "BlockElement"]) -> Optional[Union["BlockElement", TextObject]]: + if block_element is None: # skipcq: PYL-R1705 + return None + elif isinstance(block_element, dict): + if "type" in block_element: + d = copy.copy(block_element) + t = d.pop("type") + for subclass in cls._get_sub_block_elements(): + if t == subclass.type: # type: ignore + if "options" in d: + d["options"] = Option.parse_all(d["options"]) + return subclass(**d) + if t == PlainTextObject.type: # skipcq: PYL-R1705 + return PlainTextObject(**d) + elif t == MarkdownTextObject.type: + return MarkdownTextObject(**d) + elif isinstance(block_element, (TextObject, BlockElement)): + return block_element + cls.logger.warning(f"Unknown element detected and skipped ({block_element})") + return None + + +def main(): + View.__repr__ = repr_override + JsonObject.__repr__ = repr_override + BlockElement.parse = MethodType(parse, BlockElement) + input_file_path = sys.argv[1] + with open(input_file_path) as f: + d = json.load(f) + blocks = Block.parse_all(d["blocks"]) + if "type" in d and (d["type"] == "modal" or d["type"] == "home"): + if d["type"] == "modal": + view = View(type="modal", title=d["title"], submit=d["submit"], close=d["close"], blocks=blocks) + elif d["type"] == "home": + view = View(type="home", blocks=blocks) + print(repr(view)) + else: + print(repr(blocks)) + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index f80b167b..b7e0e38d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aioboto3" @@ -245,6 +245,21 @@ tornado = ["tornado (>=4.3)"] twisted = ["twisted"] zookeeper = ["kazoo"] +[[package]] +name = "astunparse" +version = "1.6.3" +description = "An AST unparser for Python" +optional = false +python-versions = "*" +files = [ + {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, + {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, +] + +[package.dependencies] +six = ">=1.6.1,<2.0" +wheel = ">=0.23.0,<1.0" + [[package]] name = "async-timeout" version = "4.0.3" @@ -844,16 +859,17 @@ dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "griffe" -version = "0.34.0" +version = "0.45.2" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." optional = false python-versions = ">=3.8" files = [ - {file = "griffe-0.34.0-py3-none-any.whl", hash = "sha256:d8bca9bd4a0880e7f71dc152de4222171d941a32b5504d77450a71a7908dfc1d"}, - {file = "griffe-0.34.0.tar.gz", hash = "sha256:48c667ad51a7f756238f798866203aeb8f9fa02d4192e25970f57f813bb37f26"}, + {file = "griffe-0.45.2-py3-none-any.whl", hash = "sha256:297ec8530d0c68e5b98ff86fb588ebc3aa3559bb5dc21f3caea8d9542a350133"}, + {file = "griffe-0.45.2.tar.gz", hash = "sha256:83ce7dcaafd8cb7f43cbf1a455155015a1eb624b1ffd93249e5e1c4a22b2fdb2"}, ] [package.dependencies] +astunparse = {version = ">=1.6", markers = "python_version < \"3.9\""} colorama = ">=0.4" [[package]] @@ -1176,16 +1192,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1363,18 +1369,18 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" -version = "1.5.0" +version = "1.10.3" description = "A Python handler for mkdocstrings." optional = false python-versions = ">=3.8" files = [ - {file = "mkdocstrings_python-1.5.0-py3-none-any.whl", hash = "sha256:f95055a23c42dd6c861f8e10201ada246c5c34def22a74dd5f73ead59c0d8958"}, - {file = "mkdocstrings_python-1.5.0.tar.gz", hash = "sha256:1b56a66b600df09b3bc787f27b86592fb4cb02e62e08e68053538725a0489175"}, + {file = "mkdocstrings_python-1.10.3-py3-none-any.whl", hash = "sha256:11ff6d21d3818fb03af82c3ea6225b1534837e17f790aa5f09626524171f949b"}, + {file = "mkdocstrings_python-1.10.3.tar.gz", hash = "sha256:321cf9c732907ab2b1fedaafa28765eaa089d89320f35f7206d00ea266889d03"}, ] [package.dependencies] -griffe = ">=0.33" -mkdocstrings = ">=0.20" +griffe = ">=0.44" +mkdocstrings = ">=0.25" [[package]] name = "mock" @@ -1936,7 +1942,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1944,16 +1949,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1970,7 +1967,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1978,7 +1974,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2963,6 +2958,20 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "wheel" +version = "0.43.0" +description = "A built-package format for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "wheel-0.43.0-py3-none-any.whl", hash = "sha256:55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81"}, + {file = "wheel-0.43.0.tar.gz", hash = "sha256:465ef92c69fa5c5da2d1cf8ac40559a8c940886afcef87dcf14b9470862f1d85"}, +] + +[package.extras] +test = ["pytest (>=6.0.0)", "setuptools (>=65)"] + [[package]] name = "wrapt" version = "1.15.0" @@ -3157,4 +3166,4 @@ sqlite = ["aiosqlite"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "5bf215ded4a21cfe5eac8464c78302fc3292c41ac39e81eb6b1dfda219b7a0da" +content-hash = "86d7f9693e5a723287ec7223b9884604dd5de7bebe0fbd9e6e624e10656fd41a" diff --git a/pyproject.toml b/pyproject.toml index cc34973b..deafacd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,8 @@ optional = true [tool.poetry.group.docs.dependencies] mkdocstrings = {extras = ["python"], version = ">=0.19,<0.26"} mkdocs-material = ">=8.5.10,<10.0.0" +griffe = "^0.45.2" +mkdocstrings-python = "^1.10.3" [tool.poetry.extras] redis = ["redis", "hiredis"] @@ -99,7 +101,7 @@ exclude = [ "build", "tests", ] -ignore = [] +ignore = ["UP006", "UP007"] select = [ "E", "F", @@ -131,6 +133,7 @@ show_column_numbers = true show_error_codes = true disallow_untyped_defs = true disallow_incomplete_defs = true +warn_redundant_casts = true [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/fake_plugins.py b/tests/fake_plugins.py index c235e85f..0f207b52 100644 --- a/tests/fake_plugins.py +++ b/tests/fake_plugins.py @@ -1,5 +1,7 @@ +import re + from machine.plugins.base import MachineBasePlugin -from machine.plugins.decorators import command, listen_to, process, respond_to +from machine.plugins.decorators import action, command, listen_to, process, respond_to class FakePlugin(MachineBasePlugin): @@ -23,6 +25,10 @@ async def command_function(self, payload): async def generator_command_function(self, payload): yield "hello" + @action(action_id=re.compile(r"my_action.*", re.IGNORECASE), block_id="my_block") + async def block_action_function(self, payload): + pass + class FakePlugin2(MachineBasePlugin): async def init(self): diff --git a/tests/handlers/__init__.py b/tests/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/handlers/conftest.py b/tests/handlers/conftest.py new file mode 100644 index 00000000..b5922eec --- /dev/null +++ b/tests/handlers/conftest.py @@ -0,0 +1,105 @@ +import re +from inspect import Signature + +import pytest +from slack_sdk.socket_mode.aiohttp import SocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest + +from machine.clients.slack import SlackClient +from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, RegisteredActions +from machine.storage import MachineBaseStorage +from machine.utils.collections import CaseInsensitiveDict +from tests.fake_plugins import FakePlugin + + +@pytest.fixture +def slack_client(mocker): + return mocker.MagicMock(spec=SlackClient) + + +@pytest.fixture +def socket_mode_client(mocker): + return mocker.MagicMock(spec=SocketModeClient) + + +@pytest.fixture +def storage(mocker): + return mocker.MagicMock(spec=MachineBaseStorage) + + +@pytest.fixture +def fake_plugin(mocker, slack_client, storage): + plugin_instance = FakePlugin(slack_client, CaseInsensitiveDict(), storage) + mocker.spy(plugin_instance, "respond_function") + mocker.spy(plugin_instance, "listen_function") + mocker.spy(plugin_instance, "process_function") + mocker.spy(plugin_instance, "command_function") + mocker.spy(plugin_instance, "generator_command_function") + mocker.spy(plugin_instance, "block_action_function") + return plugin_instance + + +@pytest.fixture +def plugin_actions(fake_plugin): + respond_fn = fake_plugin.respond_function + listen_fn = fake_plugin.listen_function + process_fn = fake_plugin.process_function + command_fn = fake_plugin.command_function + generator_command_fn = fake_plugin.generator_command_function + plugin_actions = RegisteredActions( + listen_to={ + "TestPlugin.listen_function-hi": MessageHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=listen_fn, + function_signature=Signature.from_callable(listen_fn), + regex=re.compile("hi", re.IGNORECASE), + handle_message_changed=True, + ) + }, + respond_to={ + "TestPlugin.respond_function-hello": MessageHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=respond_fn, + function_signature=Signature.from_callable(respond_fn), + regex=re.compile("hello", re.IGNORECASE), + handle_message_changed=False, + ) + }, + process={"some_event": {"TestPlugin.process_function": process_fn}}, + command={ + "/test": CommandHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=command_fn, + function_signature=Signature.from_callable(command_fn), + command="/test", + is_generator=False, + ), + "/test-generator": CommandHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=generator_command_fn, + function_signature=Signature.from_callable(generator_command_fn), + command="/test-generator", + is_generator=True, + ), + }, + block_actions={ + "TestPlugin.block_action_function-my_action.*-my_block": BlockActionHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=fake_plugin.block_action_function, + function_signature=Signature.from_callable(fake_plugin.block_action_function), + action_id_matcher=re.compile("my_action.*", re.IGNORECASE), + block_id_matcher="my_block", + ) + }, + ) + return plugin_actions + + +def gen_command_request(command: str, text: str): + payload = {"command": command, "text": text, "response_url": "https://my.webhook.com"} + return SocketModeRequest(type="slash_commands", envelope_id="x", payload=payload) diff --git a/tests/handlers/test_command_handler.py b/tests/handlers/test_command_handler.py new file mode 100644 index 00000000..5db510d0 --- /dev/null +++ b/tests/handlers/test_command_handler.py @@ -0,0 +1,46 @@ +import pytest + +from machine.handlers import create_slash_command_handler +from machine.plugins.command import Command +from tests.handlers.conftest import gen_command_request + + +def _assert_command(args, command, text): + # called with 1 positional arg and 0 kw args + assert len(args[0]) == 1 + assert len(args[1]) == 0 + # assert called with Command + assert isinstance(args[0][0], Command) + # assert command equals expected command + assert args[0][0].command == command + # assert command text equals expected text + assert args[0][0].text == text + + +@pytest.mark.asyncio +async def test_create_slash_command_handler(plugin_actions, fake_plugin, socket_mode_client, slack_client): + handler = create_slash_command_handler(plugin_actions, slack_client) + await handler(socket_mode_client, gen_command_request("/test", "foo")) + assert fake_plugin.command_function.call_count == 1 + args = fake_plugin.command_function.call_args + _assert_command(args, "/test", "foo") + socket_mode_client.send_socket_mode_response.assert_called_once() + resp = socket_mode_client.send_socket_mode_response.call_args.args[0] + assert resp.envelope_id == "x" + assert resp.payload is None + assert fake_plugin.generator_command_function.call_count == 0 + + +@pytest.mark.asyncio +async def test_create_slash_command_handler_generator(plugin_actions, fake_plugin, socket_mode_client, slack_client): + handler = create_slash_command_handler(plugin_actions, slack_client) + await handler(socket_mode_client, gen_command_request("/test-generator", "bar")) + assert fake_plugin.generator_command_function.call_count == 1 + args = fake_plugin.generator_command_function.call_args + _assert_command(args, "/test-generator", "bar") + socket_mode_client.send_socket_mode_response.assert_called_once() + resp = socket_mode_client.send_socket_mode_response.call_args.args[0] + assert resp.envelope_id == "x" + # SocketModeResponse will transform a string into a dict with `text` as only key + assert resp.payload == {"text": "hello"} + assert fake_plugin.command_function.call_count == 0 diff --git a/tests/handlers/test_event_handler.py b/tests/handlers/test_event_handler.py new file mode 100644 index 00000000..d2a19a6b --- /dev/null +++ b/tests/handlers/test_event_handler.py @@ -0,0 +1,21 @@ +import pytest +from slack_sdk.socket_mode.request import SocketModeRequest + +from machine.handlers import create_generic_event_handler + + +def _gen_event_request(event_type: str): + return SocketModeRequest(type="events_api", envelope_id="x", payload={"event": {"type": event_type, "foo": "bar"}}) + + +@pytest.mark.asyncio +async def test_create_generic_event_handler(plugin_actions, fake_plugin, socket_mode_client): + handler = create_generic_event_handler(plugin_actions) + await handler(socket_mode_client, _gen_event_request("other_event")) + assert fake_plugin.process_function.call_count == 0 + await handler(socket_mode_client, _gen_event_request("some_event")) + assert fake_plugin.process_function.call_count == 1 + args = fake_plugin.process_function.call_args + assert len(args[0]) == 1 + assert len(args[1]) == 0 + assert args[0][0] == {"type": "some_event", "foo": "bar"} diff --git a/tests/handlers/test_interactive_handler.py b/tests/handlers/test_interactive_handler.py new file mode 100644 index 00000000..beb7c6d0 --- /dev/null +++ b/tests/handlers/test_interactive_handler.py @@ -0,0 +1,91 @@ +import re + +import pytest +from slack_sdk.socket_mode.request import SocketModeRequest + +from machine.handlers.interactive_handler import _matches, create_interactive_handler +from machine.plugins.block_action import BlockAction + + +def _gen_block_action_request(action_id, block_id): + payload = { + "type": "block_actions", + "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"}, + "api_app_id": "A12345678", + "token": "verification_token", + "container": { + "type": "message", + "message_ts": "1234567890.123456", + "channel_id": "C12345678", + "is_ephemeral": False, + }, + "channel": {"id": "C12345678", "name": "channel-name"}, + "message": { + "type": "message", + "user": "U87654321", + "ts": "1234567890.123456", + "bot_id": "B12345678", + "app_id": "A12345678", + "text": "Hello, world!", + "team": "T12345678", + "blocks": [ + { + "type": "actions", + "block_id": block_id, + "elements": [ + { + "type": "button", + "action_id": action_id, + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "U12345678", + }, + ], + }, + ], + }, + "state": {"values": {}}, + "trigger_id": "1234567890.123456", + "team": {"id": "T12345678", "domain": "workspace-domain"}, + "enterprise": None, + "is_enterprise_install": False, + "actions": [ + { + "type": "button", + "action_id": action_id, + "block_id": block_id, + "action_ts": "1234567890.123456", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "value": "U12345678", + "style": "primary", + } + ], + "response_url": "https://hooks.slack.com/actions/T12345678/1234567890/1234567890", + } + return SocketModeRequest(type="interactive", envelope_id="x", payload=payload) + + +def test_matches(): + assert _matches(None, "my_action_1") is True + assert _matches("my_action_1", "my_action_1") is True + assert _matches("my_action_1", "my_action_2") is False + assert _matches(re.compile("my_action.*"), "my_action_3") is True + assert _matches(re.compile("my_action.*"), "my_block_4") is False + + +@pytest.mark.asyncio +async def test_create_interactive_handler_for_block_actions( + plugin_actions, fake_plugin, socket_mode_client, slack_client +): + handler = create_interactive_handler(plugin_actions, slack_client) + request = _gen_block_action_request("my_action_1", "my_block") + await handler(socket_mode_client, request) + assert fake_plugin.block_action_function.call_count == 1 + args = fake_plugin.block_action_function.call_args + assert isinstance(args[0][0], BlockAction) + assert args[0][0].triggered_action.action_id == "my_action_1" + assert args[0][0].triggered_action.block_id == "my_block" + socket_mode_client.send_socket_mode_response.assert_called_once() + resp = socket_mode_client.send_socket_mode_response.call_args.args[0] + assert resp.envelope_id == "x" + assert resp.payload is None diff --git a/tests/handlers/test_logging.py b/tests/handlers/test_logging.py new file mode 100644 index 00000000..3b8ae886 --- /dev/null +++ b/tests/handlers/test_logging.py @@ -0,0 +1,18 @@ +import pytest +from structlog.testing import capture_logs + +from machine.handlers import log_request +from tests.handlers.conftest import gen_command_request + + +@pytest.mark.asyncio +async def test_request_logger_handler(socket_mode_client): + with capture_logs() as cap_logs: + await log_request(socket_mode_client, gen_command_request("/test", "foo")) + log_event = cap_logs[0] + assert log_event["event"] == "Request received" + assert log_event["type"] == "slash_commands" + assert log_event["request"] == { + "envelope_id": "x", + "payload": {"command": "/test", "text": "foo", "response_url": "https://my.webhook.com"}, + } diff --git a/tests/handlers/test_message_handler.py b/tests/handlers/test_message_handler.py new file mode 100644 index 00000000..053879f3 --- /dev/null +++ b/tests/handlers/test_message_handler.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import re + +import pytest + +from machine.handlers.message_handler import _check_bot_mention, generate_message_matcher, handle_message +from machine.plugins.message import Message + + +@pytest.fixture +def message_matcher(): + return generate_message_matcher({}) + + +def _gen_msg_event(text: str, channel_type: str = "channel") -> dict[str, str]: + return {"type": "message", "text": text, "channel_type": channel_type, "user": "user1"} + + +def test_generate_message_matcher(): + no_aliases_settings = {} + one_alias_settings = {"ALIASES": "!"} + two_aliases_settings = {"ALIASES": "!,$"} + assert generate_message_matcher(no_aliases_settings) == re.compile( + r"^(?:<@(?P\w+)>:?|(?P\w+):) ?(?P.*)$", re.DOTALL + ) + assert generate_message_matcher(one_alias_settings) == re.compile( + r"^(?:<@(?P\w+)>:?|(?P\w+):|(?P!)) ?(?P.*)$", re.DOTALL + ) + assert generate_message_matcher(two_aliases_settings) == re.compile( + rf"^(?:<@(?P\w+)>:?|(?P\w+):|(?P!|{re.escape('$')})) ?(?P.*)$", re.DOTALL + ) + + +def test_check_bot_mention(): + bot_name = "superbot" + bot_id = "123" + message_matcher = generate_message_matcher({"ALIASES": "!,$"}) + + normal_msg_event = _gen_msg_event("hi") + event = _check_bot_mention(normal_msg_event, bot_name, bot_id, message_matcher) + assert event is None + + mention_msg_event = _gen_msg_event("<@123> hi") + event = _check_bot_mention(mention_msg_event, bot_name, bot_id, message_matcher) + assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} + + mention_msg_event_username = _gen_msg_event("superbot: hi") + event = _check_bot_mention(mention_msg_event_username, bot_name, bot_id, message_matcher) + assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} + + mention_msg_event_group = _gen_msg_event("<@123> hi", channel_type="group") + event = _check_bot_mention(mention_msg_event_group, bot_name, bot_id, message_matcher) + assert event == {"text": "hi", "channel_type": "group", "type": "message", "user": "user1"} + + mention_msg_event_other_user = _gen_msg_event("<@456> hi") + event = _check_bot_mention(mention_msg_event_other_user, bot_name, bot_id, message_matcher) + assert event is None + + mention_msg_event_dm = _gen_msg_event("hi", channel_type="im") + event = _check_bot_mention(mention_msg_event_dm, bot_name, bot_id, message_matcher) + assert event == {"text": "hi", "channel_type": "im", "type": "message", "user": "user1"} + + mention_msg_event_dm_with_user = _gen_msg_event("<@123> hi", channel_type="im") + event = _check_bot_mention(mention_msg_event_dm_with_user, bot_name, bot_id, message_matcher) + assert event == {"text": "hi", "channel_type": "im", "type": "message", "user": "user1"} + + mention_msg_event_alias_1 = _gen_msg_event("!hi") + event = _check_bot_mention(mention_msg_event_alias_1, bot_name, bot_id, message_matcher) + assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} + + mention_msg_event_alias_2 = _gen_msg_event("$hi") + event = _check_bot_mention(mention_msg_event_alias_2, bot_name, bot_id, message_matcher) + assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} + + mention_msg_event_wrong_alias = _gen_msg_event("?hi") + event = _check_bot_mention(mention_msg_event_wrong_alias, bot_name, bot_id, message_matcher) + assert event is None + + +def _assert_message(args, text): + # called with 1 positional arg and 0 kw args + assert len(args[0]) == 1 + assert len(args[1]) == 0 + # assert called with Message + assert isinstance(args[0][0], Message) + # assert message equals expected text + assert args[0][0].text == text + + +@pytest.mark.asyncio +async def test_handle_message_listen_to(plugin_actions, fake_plugin, slack_client, message_matcher): + bot_name = "superbot" + bot_id = "123" + msg_event = _gen_msg_event("hi") + + await handle_message(msg_event, bot_name, bot_id, plugin_actions, message_matcher, slack_client, True) + assert fake_plugin.listen_function.call_count == 1 + assert fake_plugin.respond_function.call_count == 0 + args = fake_plugin.listen_function.call_args + _assert_message(args, "hi") + + +@pytest.mark.asyncio +async def test_handle_message_respond_to(plugin_actions, fake_plugin, slack_client, message_matcher): + bot_name = "superbot" + bot_id = "123" + msg_event = _gen_msg_event("<@123> hello") + await handle_message(msg_event, bot_name, bot_id, plugin_actions, message_matcher, slack_client, True) + assert fake_plugin.respond_function.call_count == 1 + assert fake_plugin.listen_function.call_count == 0 + args = fake_plugin.respond_function.call_args + _assert_message(args, "hello") + + +@pytest.mark.asyncio +async def test_handle_message_changed(plugin_actions, fake_plugin, slack_client, message_matcher): + bot_name = "superbot" + bot_id = "123" + msg_event = { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "hi", + "user": "user1", + }, + "channel_type": "channel", + "channel": "C123", + } + await handle_message(msg_event, bot_name, bot_id, plugin_actions, message_matcher, slack_client, True) + assert fake_plugin.respond_function.call_count == 0 + assert fake_plugin.listen_function.call_count == 1 + args = fake_plugin.listen_function.call_args + _assert_message(args, "hi") diff --git a/tests/models/example_payloads/__init__.py b/tests/models/example_payloads/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/models/example_payloads/block_action_button.py b/tests/models/example_payloads/block_action_button.py new file mode 100644 index 00000000..f9357c76 --- /dev/null +++ b/tests/models/example_payloads/block_action_button.py @@ -0,0 +1,116 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715342584.374249", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7100812555332.842445099040.18c37a32faed2f3ed2ed54a7c0362821", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715342584.374249", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "VSVtX", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "IHmrg", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "oGuUs"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + {"type": "divider", "block_id": "dGpE7"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-10"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + } + }, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7091708574470/C3UHEA7tCHrPcxgvTnKDXVNl", + "actions": [ + { + "action_id": "interactions_deny", + "block_id": "interactions_confirmation", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "value": "UQEUMSA0K", + "style": "danger", + "type": "button", + "action_ts": "1715342739.677759", + } + ], +} diff --git a/tests/models/example_payloads/block_action_button2.py b/tests/models/example_payloads/block_action_button2.py new file mode 100644 index 00000000..f6bb9961 --- /dev/null +++ b/tests/models/example_payloads/block_action_button2.py @@ -0,0 +1,116 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715342584.374249", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7092226209862.842445099040.d6e1f15d427d6d40b7f5daf5f215d48e", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715342584.374249", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "VSVtX", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "IHmrg", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "oGuUs"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + {"type": "divider", "block_id": "dGpE7"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-10"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + } + }, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7111523917505/HtlLacfriGsA3bsBjjrpqfdf", + "actions": [ + { + "action_id": "interactions_approve", + "block_id": "interactions_confirmation", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "value": "UQEUMSA0K", + "style": "primary", + "type": "button", + "action_ts": "1715349026.773366", + } + ], +} diff --git a/tests/models/example_payloads/block_action_button_no_value.py b/tests/models/example_payloads/block_action_button_no_value.py new file mode 100644 index 00000000..3c346671 --- /dev/null +++ b/tests/models/example_payloads/block_action_button_no_value.py @@ -0,0 +1,148 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1716206801.973899", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7146094638420.842445099040.8fbb84c74d488a7ec2646219cbb845a1", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1716206801.973899", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "Vote for lunch", + "team": "TQSD32X16", + "blocks": [ + { + "type": "section", + "block_id": "5wxLW", + "text": { + "type": "mrkdwn", + "text": "*Where should we order lunch from?* Poll by ", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "VcIxg"}, + { + "type": "section", + "block_id": "q3Yif", + "text": { + "type": "mrkdwn", + "text": ":sushi: *Ace Wasabi Rock-n-Roll Sushi Bar*\nThe best landlocked sushi restaurant.", + "verbatim": False, + }, + "accessory": { + "type": "button", + "action_id": "sushi", + "text": {"type": "plain_text", "text": "Vote", "emoji": True}, + }, + }, + { + "type": "context", + "block_id": "aLe7H", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_1.png", + "alt_text": "Michael Scott", + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_2.png", + "alt_text": "Dwight Schrute", + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_3.png", + "alt_text": "Pam Beasely", + }, + {"type": "plain_text", "text": "3 votes", "emoji": True}, + ], + }, + { + "type": "section", + "block_id": "iwv9r", + "text": { + "type": "mrkdwn", + "text": ":hamburger: *Super Hungryman Hamburgers*\nOnly for the hungriest of the hungry.", + "verbatim": False, + }, + "accessory": { + "type": "button", + "action_id": "hamburger", + "text": {"type": "plain_text", "text": "Vote", "emoji": True}, + }, + }, + { + "type": "context", + "block_id": "IUs3i", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_4.png", + "alt_text": "Angela", + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_2.png", + "alt_text": "Dwight Schrute", + }, + {"type": "plain_text", "text": "2 votes", "emoji": True}, + ], + }, + { + "type": "section", + "block_id": "n5Exn", + "text": { + "type": "mrkdwn", + "text": ":ramen: *Kagawa-Ya Udon Noodle Shop*\nDo you like to shop for noodles? We have noodles.", + "verbatim": False, + }, + "accessory": { + "type": "button", + "action_id": "ramen", + "text": {"type": "plain_text", "text": "Vote", "emoji": True}, + }, + }, + { + "type": "context", + "block_id": "DRo0X", + "elements": [{"type": "mrkdwn", "text": "No votes", "verbatim": False}], + }, + {"type": "divider", "block_id": "fSYFi"}, + { + "type": "actions", + "block_id": "C7q4o", + "elements": [ + { + "type": "button", + "action_id": "hsnL4", + "text": {"type": "plain_text", "text": "Add a suggestion", "emoji": True}, + "value": "click_me_123", + } + ], + }, + ], + }, + "state": {"values": {}}, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7156287676161/TySSUfp835V0G9PQk8Z5pN3w", + "actions": [ + { + "action_id": "sushi", + "block_id": "q3Yif", + "text": {"type": "plain_text", "text": "Vote", "emoji": True}, + "type": "button", + "action_ts": "1716206811.107343", + } + ], +} diff --git a/tests/models/example_payloads/block_action_checkboxes.py b/tests/models/example_payloads/block_action_checkboxes.py new file mode 100644 index 00000000..35127b6d --- /dev/null +++ b/tests/models/example_payloads/block_action_checkboxes.py @@ -0,0 +1,115 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715342584.374249", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7098336993394.842445099040.6c320ffdf11b486907db23a14dca9744", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715342584.374249", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "VSVtX", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "IHmrg", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "oGuUs"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + {"type": "divider", "block_id": "dGpE7"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-10"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"} + ], + } + }, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7100809220404/J1x2MJsAOQiYr7lWD97aDdeE", + "actions": [ + { + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"} + ], + "action_id": "select_options", + "block_id": "checkboxes", + "type": "checkboxes", + "action_ts": "1715342693.439462", + } + ], +} diff --git a/tests/models/example_payloads/block_action_checkboxes2.py b/tests/models/example_payloads/block_action_checkboxes2.py new file mode 100644 index 00000000..1b81ec37 --- /dev/null +++ b/tests/models/example_payloads/block_action_checkboxes2.py @@ -0,0 +1,117 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715342584.374249", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7100810075572.842445099040.e6805d59a6560dc408540a7a2f29d8f9", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715342584.374249", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "VSVtX", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "IHmrg", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "oGuUs"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + {"type": "divider", "block_id": "dGpE7"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-10"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + } + }, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7111003805425/fAzkbzu4uffw9Vx0ujFP6yOR", + "actions": [ + { + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + "action_id": "select_options", + "block_id": "checkboxes", + "type": "checkboxes", + "action_ts": "1715342705.699394", + } + ], +} diff --git a/tests/models/example_payloads/block_action_datepicker.py b/tests/models/example_payloads/block_action_datepicker.py new file mode 100644 index 00000000..72a66e29 --- /dev/null +++ b/tests/models/example_payloads/block_action_datepicker.py @@ -0,0 +1,106 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715342584.374249", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7121121788112.842445099040.3fc95500d3f29b5df5e415a4cae90968", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715342584.374249", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "VSVtX", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "IHmrg", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "oGuUs"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + {"type": "divider", "block_id": "dGpE7"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": None}}, + "checkboxes": {"select_options": {"type": "checkboxes", "selected_options": []}}, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7110998152385/Nj7Cjedo0fJn1jS1nAevJoyl", + "actions": [ + { + "type": "datepicker", + "action_id": "pick_date", + "block_id": "date_picker", + "selected_date": None, + "action_ts": "1715342625.048691", + } + ], +} diff --git a/tests/models/example_payloads/block_action_datepicker2.py b/tests/models/example_payloads/block_action_datepicker2.py new file mode 100644 index 00000000..1a77e6d6 --- /dev/null +++ b/tests/models/example_payloads/block_action_datepicker2.py @@ -0,0 +1,106 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715342584.374249", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7095422467397.842445099040.cd3bd5114a4256367fb9afda89e67846", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715342584.374249", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "VSVtX", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "IHmrg", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "oGuUs"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + {"type": "divider", "block_id": "dGpE7"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-10"}}, + "checkboxes": {"select_options": {"type": "checkboxes", "selected_options": []}}, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7100804463908/4X4jn4jZ1n8qPGcZkv2eptyU", + "actions": [ + { + "type": "datepicker", + "action_id": "pick_date", + "block_id": "date_picker", + "selected_date": "2024-05-10", + "action_ts": "1715342625.621904", + } + ], +} diff --git a/tests/models/example_payloads/block_action_in_modal.py b/tests/models/example_payloads/block_action_in_modal.py new file mode 100644 index 00000000..149df282 --- /dev/null +++ b/tests/models/example_payloads/block_action_in_modal.py @@ -0,0 +1,170 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": {"type": "view", "view_id": "V072ZQ2S56G"}, + "trigger_id": "7096437424069.842445099040.aa44504892bcf8c9f7c3c5def0b4c3e7", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "view": { + "id": "V072ZQ2S56G", + "team_id": "TQSD32X16", + "type": "modal", + "blocks": [ + { + "type": "section", + "block_id": "x5guW", + "text": { + "type": "plain_text", + "text": ":wave: Hey David!\n\nWe'd love to hear from you how we can make this place the best place you’ve ever worked.", # noqa: E501 + "emoji": True, + }, + }, + {"type": "divider", "block_id": "wdCiM"}, + { + "type": "input", + "block_id": "working_here", + "label": {"type": "plain_text", "text": "You enjoy working here at Pistachio & Co", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "working_here_options", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "4j/Gd", + "label": { + "type": "plain_text", + "text": "What do you want for our team weekly lunch?", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "placeholder": {"type": "plain_text", "text": "Select your favorites", "emoji": True}, + "options": [ + { + "text": {"type": "plain_text", "text": ":pizza: Pizza", "emoji": True}, + "value": "value-0", + }, + { + "text": {"type": "plain_text", "text": ":fried_shrimp: Thai food", "emoji": True}, + "value": "value-1", + }, + { + "text": {"type": "plain_text", "text": ":desert_island: Hawaiian", "emoji": True}, + "value": "value-2", + }, + { + "text": {"type": "plain_text", "text": ":meat_on_bone: Texas BBQ", "emoji": True}, + "value": "value-3", + }, + { + "text": {"type": "plain_text", "text": ":hamburger: Burger", "emoji": True}, + "value": "value-4", + }, + {"text": {"type": "plain_text", "text": ":taco: Tacos", "emoji": True}, "value": "value-5"}, + { + "text": {"type": "plain_text", "text": ":green_salad: Salad", "emoji": True}, + "value": "value-6", + }, + { + "text": {"type": "plain_text", "text": ":stew: Indian", "emoji": True}, + "value": "value-7", + }, + ], + "action_id": "nfisX", + }, + }, + { + "type": "input", + "block_id": "it2e/", + "label": { + "type": "plain_text", + "text": "What can we do to improve your experience working here?", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "Ni5tz", + }, + }, + { + "type": "input", + "block_id": "DVhIO", + "label": {"type": "plain_text", "text": "Anything else you want to tell us?", "emoji": True}, + "optional": True, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "zJn8J", + }, + }, + { + "type": "section", + "block_id": "sLIvA", + "text": {"type": "mrkdwn", "text": "Pick a date for the deadline.", "verbatim": False}, + "accessory": { + "type": "datepicker", + "action_id": "datepicker-action", + "initial_date": "1990-04-28", + "placeholder": {"type": "plain_text", "text": "Select a date", "emoji": True}, + }, + }, + ], + "private_metadata": "", + "callback_id": "", + "state": { + "values": { + "working_here": {"working_here_options": {"type": "radio_buttons", "selected_option": None}}, + "4j/Gd": {"nfisX": {"type": "multi_static_select", "selected_options": []}}, + "it2e/": {"Ni5tz": {"type": "plain_text_input", "value": None}}, + "DVhIO": {"zJn8J": {"type": "plain_text_input", "value": None}}, + "sLIvA": {"datepicker-action": {"type": "datepicker", "selected_date": "1990-04-18"}}, + } + }, + "hash": "1715353805.hwG4sVuh", + "title": {"type": "plain_text", "text": "Workplace check-in", "emoji": True}, + "clear_on_close": False, + "notify_on_close": False, + "close": {"type": "plain_text", "text": "Cancel", "emoji": True}, + "submit": {"type": "plain_text", "text": "Submit", "emoji": True}, + "previous_view_id": None, + "root_view_id": "V072ZQ2S56G", + "app_id": "A039QKQ6G1E", + "external_id": "", + "app_installed_team_id": "TQSD32X16", + "bot_id": "B0390TCABQB", + }, + "actions": [ + { + "type": "datepicker", + "action_id": "datepicker-action", + "block_id": "sLIvA", + "selected_date": "1990-04-18", + "initial_date": "1990-04-28", + "action_ts": "1715353810.212145", + } + ], +} diff --git a/tests/models/example_payloads/block_action_multi_select.py b/tests/models/example_payloads/block_action_multi_select.py new file mode 100644 index 00000000..1b265857 --- /dev/null +++ b/tests/models/example_payloads/block_action_multi_select.py @@ -0,0 +1,155 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715356149.379229", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7122371158416.842445099040.14873656a33cf59dce0b151b9714eb6d", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715356149.379229", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "E6J8T", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "u4lX1", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "f2j6J"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + {"type": "divider", "block_id": "Gl0qN"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": None}}, + "checkboxes": {"select_options": {"type": "checkboxes", "selected_options": []}}, + "email": {"provide_email": {"type": "email_text_input", "value": "daan@dv.email"}}, + "startrek": { + "select_menu_options": { + "type": "multi_static_select", + "selected_options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + ], + } + }, + "number": {"enter_number": {"type": "number_input"}}, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7096671829205/JIOYTi7RdhNlgbcZmRuPviKL", + "actions": [ + { + "type": "multi_static_select", + "action_id": "select_menu_options", + "block_id": "startrek", + "selected_options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + ], + "action_ts": "1715356169.749052", + } + ], +} diff --git a/tests/models/example_payloads/block_action_multi_select_channel.py b/tests/models/example_payloads/block_action_multi_select_channel.py new file mode 100644 index 00000000..39e6bc31 --- /dev/null +++ b/tests/models/example_payloads/block_action_multi_select_channel.py @@ -0,0 +1,252 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715371664.874619", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7094314991846.842445099040.3b802b5be0c5fd32acf8c2b315ac2ba0", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715371664.874619", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "GNwo8", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "7fQ6g", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "nGzaK"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "multi_select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + { + "type": "section", + "block_id": "7UoX7", + "text": {"type": "mrkdwn", "text": "Pick a number", "verbatim": False}, + "accessory": { + "type": "overflow", + "action_id": "pick_overflow_option", + "options": [ + {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + {"text": {"type": "plain_text", "text": "Two", "emoji": True}, "value": "two"}, + {"text": {"type": "plain_text", "text": "Three", "emoji": True}, "value": "three"}, + ], + }, + }, + { + "type": "input", + "block_id": "feelings", + "label": {"type": "plain_text", "text": "Your feelings", "emoji": True}, + "hint": {"type": "plain_text", "text": "Be honest...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "provide_feelings", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + { + "type": "input", + "block_id": "radio_demonstration", + "label": {"type": "plain_text", "text": "You enjoy this selection of input elements", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "select_radio_option", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "alphabet", + "label": { + "type": "plain_text", + "text": "What is your favorite character in the alphabet?", + "emoji": True, + }, + "hint": {"type": "plain_text", "text": "Choose wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "AAAA", "emoji": True}, "value": "A"}, + {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + {"text": {"type": "plain_text", "text": "CCCC", "emoji": True}, "value": "C"}, + ], + }, + }, + { + "type": "input", + "block_id": "time_picker", + "label": {"type": "plain_text", "text": "Pick a time", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your time wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "timepicker", "action_id": "pick_time", "initial_time": "13:37"}, + }, + { + "type": "input", + "block_id": "url", + "label": {"type": "plain_text", "text": "Provide a URL", "emoji": True}, + "hint": {"type": "plain_text", "text": "URLs are cool...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "url_text_input", + "action_id": "provide_url", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + { + "type": "input", + "block_id": "channel_select", + "label": {"type": "plain_text", "text": "Select a channel", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose a channel...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "multi_channels_select", "action_id": "select_channel"}, + }, + {"type": "divider", "block_id": "3r4GK"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": None}}, + "checkboxes": {"select_options": {"type": "checkboxes", "selected_options": []}}, + "email": {"provide_email": {"type": "email_text_input"}}, + "startrek": {"multi_select_menu_options": {"type": "multi_static_select", "selected_options": []}}, + "number": {"enter_number": {"type": "number_input"}}, + "feelings": {"provide_feelings": {"type": "plain_text_input", "value": None}}, + "radio_demonstration": {"select_radio_option": {"type": "radio_buttons", "selected_option": None}}, + "alphabet": {"select_menu_options": {"type": "static_select", "selected_option": None}}, + "time_picker": {"pick_time": {"type": "timepicker", "selected_time": "13:37"}}, + "url": {"provide_url": {"type": "url_text_input"}}, + "channel_select": { + "select_channel": {"type": "multi_channels_select", "selected_channels": ["CQS7DP10C", "CQSLXBJP2"]} + }, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7123736364672/xmncnAR8vyWxncVBu6tfkq8p", + "actions": [ + { + "type": "multi_channels_select", + "action_id": "select_channel", + "block_id": "channel_select", + "selected_channels": ["CQS7DP10C", "CQSLXBJP2"], + "action_ts": "1715371669.732559", + } + ], +} diff --git a/tests/models/example_payloads/block_action_overflow.py b/tests/models/example_payloads/block_action_overflow.py new file mode 100644 index 00000000..fdc18c56 --- /dev/null +++ b/tests/models/example_payloads/block_action_overflow.py @@ -0,0 +1,262 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715372904.225479", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7094416576822.842445099040.33c75704b6e76700e81e8b3961dd6993", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715372904.225479", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "xKOBl", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "FwV7v", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "aDqP2"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "multi_select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + { + "type": "section", + "block_id": "Ygh+b", + "text": {"type": "mrkdwn", "text": "Pick a number", "verbatim": False}, + "accessory": { + "type": "overflow", + "action_id": "pick_overflow_option", + "options": [ + {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + {"text": {"type": "plain_text", "text": "Two", "emoji": True}, "value": "two"}, + {"text": {"type": "plain_text", "text": "Three", "emoji": True}, "value": "three"}, + ], + }, + }, + { + "type": "input", + "block_id": "feelings", + "label": {"type": "plain_text", "text": "Your feelings", "emoji": True}, + "hint": {"type": "plain_text", "text": "Be honest...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "provide_feelings", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + { + "type": "input", + "block_id": "radio_demonstration", + "label": {"type": "plain_text", "text": "You enjoy this selection of input elements", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "select_radio_option", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "alphabet", + "label": { + "type": "plain_text", + "text": "What is your favorite character in the alphabet?", + "emoji": True, + }, + "hint": {"type": "plain_text", "text": "Choose wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "AAAA", "emoji": True}, "value": "A"}, + {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + {"text": {"type": "plain_text", "text": "CCCC", "emoji": True}, "value": "C"}, + ], + }, + }, + { + "type": "input", + "block_id": "time_picker", + "label": {"type": "plain_text", "text": "Pick a time", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your time wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "timepicker", "action_id": "pick_time"}, + }, + { + "type": "input", + "block_id": "url", + "label": {"type": "plain_text", "text": "Provide a URL", "emoji": True}, + "hint": {"type": "plain_text", "text": "URLs are cool...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "url_text_input", + "action_id": "provide_url", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + { + "type": "input", + "block_id": "channels_select", + "label": {"type": "plain_text", "text": "Select channels", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose channels...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "multi_channels_select", "action_id": "select_channel"}, + }, + { + "type": "input", + "block_id": "conversation_select", + "label": {"type": "plain_text", "text": "Select a conversation", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose a conversation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "conversations_select", "action_id": "select_conversation"}, + }, + {"type": "divider", "block_id": "tKP6s"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": None}}, + "checkboxes": {"select_options": {"type": "checkboxes", "selected_options": []}}, + "email": {"provide_email": {"type": "email_text_input"}}, + "startrek": {"multi_select_menu_options": {"type": "multi_static_select", "selected_options": []}}, + "number": {"enter_number": {"type": "number_input"}}, + "feelings": {"provide_feelings": {"type": "plain_text_input", "value": None}}, + "radio_demonstration": {"select_radio_option": {"type": "radio_buttons", "selected_option": None}}, + "alphabet": {"select_menu_options": {"type": "static_select", "selected_option": None}}, + "time_picker": {"pick_time": {"type": "timepicker", "selected_time": None}}, + "url": {"provide_url": {"type": "url_text_input"}}, + "channels_select": {"select_channel": {"type": "multi_channels_select", "selected_channels": []}}, + "conversation_select": { + "select_conversation": {"type": "conversations_select", "selected_conversation": "UQJJW6VG9"} + }, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7123837948864/Pi42rIv2LtxriiV59y6PgkHE", + "actions": [ + { + "type": "overflow", + "action_id": "pick_overflow_option", + "block_id": "Ygh+b", + "selected_option": {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + "action_ts": "1715373051.167015", + } + ], +} diff --git a/tests/models/example_payloads/block_action_radio_button.py b/tests/models/example_payloads/block_action_radio_button.py new file mode 100644 index 00000000..141f1e7c --- /dev/null +++ b/tests/models/example_payloads/block_action_radio_button.py @@ -0,0 +1,268 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715360075.165019", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7093332165398.842445099040.c0c87f6a1b23949758a3cb845f32ba51", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715360075.165019", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "7wq18", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "hOjar", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "H5Jbh"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "multi_select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + { + "type": "section", + "block_id": "xJwX4", + "text": {"type": "mrkdwn", "text": "Pick a number", "verbatim": False}, + "accessory": { + "type": "overflow", + "action_id": "pick_overflow_option", + "options": [ + {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + {"text": {"type": "plain_text", "text": "Two", "emoji": True}, "value": "two"}, + {"text": {"type": "plain_text", "text": "Three", "emoji": True}, "value": "three"}, + ], + }, + }, + { + "type": "input", + "block_id": "feelings", + "label": {"type": "plain_text", "text": "Your feelings", "emoji": True}, + "hint": {"type": "plain_text", "text": "Be honest...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "provide_feelings", + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + { + "type": "input", + "block_id": "radio_demonstration", + "label": { + "type": "plain_text", + "text": "You enjoy this selection of input elements", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "select_radio_option", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "alphabet", + "label": { + "type": "plain_text", + "text": "What is your favorite character in the alphabet?", + "emoji": True, + }, + "hint": {"type": "plain_text", "text": "Choose wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "AAAA", "emoji": True}, "value": "A"}, + {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + {"text": {"type": "plain_text", "text": "CCCC", "emoji": True}, "value": "C"}, + ], + }, + }, + { + "type": "input", + "block_id": "time_picker", + "label": {"type": "plain_text", "text": "Pick a time", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your time wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "timepicker", "action_id": "pick_time", "initial_time": "13:37"}, + }, + { + "type": "input", + "block_id": "url", + "label": {"type": "plain_text", "text": "Provide a URL", "emoji": True}, + "hint": {"type": "plain_text", "text": "URLs are cool...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "url_text_input", + "action_id": "provide_url", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + {"type": "divider", "block_id": "GuWGz"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-24"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + } + }, + "email": {"provide_email": {"type": "email_text_input", "value": "daan@dv.email"}}, + "startrek": { + "multi_select_menu_options": { + "type": "multi_static_select", + "selected_options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + ], + } + }, + "number": {"enter_number": {"type": "number_input", "value": "55"}}, + "feelings": {"provide_feelings": {"type": "plain_text_input", "value": "I'm great!"}}, + "radio_demonstration": { + "select_radio_option": { + "type": "radio_buttons", + "selected_option": { + "text": {"type": "plain_text", "text": "Disagree", "emoji": True}, + "value": "4", + }, + } + }, + "alphabet": {"select_menu_options": {"type": "static_select", "selected_option": None}}, + "time_picker": {"pick_time": {"type": "timepicker", "selected_time": "13:37"}}, + "url": {"provide_url": {"type": "url_text_input"}}, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7097054223925/V9zzmH33CCApEYIn9l8fjlab", + "actions": [ + { + "action_id": "select_radio_option", + "block_id": "radio_demonstration", + "selected_option": {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + "type": "radio_buttons", + "action_ts": "1715360149.835609", + } + ], +} diff --git a/tests/models/example_payloads/block_action_select.py b/tests/models/example_payloads/block_action_select.py new file mode 100644 index 00000000..0e51a833 --- /dev/null +++ b/tests/models/example_payloads/block_action_select.py @@ -0,0 +1,276 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715360075.165019", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7102459401924.842445099040.e8d07ec409bb6aee5271ced8f4487782", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715360075.165019", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "7wq18", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "hOjar", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "H5Jbh"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "multi_select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + { + "type": "section", + "block_id": "xJwX4", + "text": {"type": "mrkdwn", "text": "Pick a number", "verbatim": False}, + "accessory": { + "type": "overflow", + "action_id": "pick_overflow_option", + "options": [ + {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + {"text": {"type": "plain_text", "text": "Two", "emoji": True}, "value": "two"}, + {"text": {"type": "plain_text", "text": "Three", "emoji": True}, "value": "three"}, + ], + }, + }, + { + "type": "input", + "block_id": "feelings", + "label": {"type": "plain_text", "text": "Your feelings", "emoji": True}, + "hint": {"type": "plain_text", "text": "Be honest...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "provide_feelings", + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + { + "type": "input", + "block_id": "radio_demonstration", + "label": { + "type": "plain_text", + "text": "You enjoy this selection of input elements", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "select_radio_option", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "alphabet", + "label": { + "type": "plain_text", + "text": "What is your favorite character in the alphabet?", + "emoji": True, + }, + "hint": {"type": "plain_text", "text": "Choose wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "AAAA", "emoji": True}, "value": "A"}, + {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + {"text": {"type": "plain_text", "text": "CCCC", "emoji": True}, "value": "C"}, + ], + }, + }, + { + "type": "input", + "block_id": "time_picker", + "label": {"type": "plain_text", "text": "Pick a time", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your time wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "timepicker", "action_id": "pick_time", "initial_time": "13:37"}, + }, + { + "type": "input", + "block_id": "url", + "label": {"type": "plain_text", "text": "Provide a URL", "emoji": True}, + "hint": {"type": "plain_text", "text": "URLs are cool...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "url_text_input", + "action_id": "provide_url", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + {"type": "divider", "block_id": "GuWGz"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-24"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + } + }, + "email": {"provide_email": {"type": "email_text_input", "value": "daan@dv.email"}}, + "startrek": { + "multi_select_menu_options": { + "type": "multi_static_select", + "selected_options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + ], + } + }, + "number": {"enter_number": {"type": "number_input", "value": "55"}}, + "feelings": {"provide_feelings": {"type": "plain_text_input", "value": "I'm great!"}}, + "radio_demonstration": { + "select_radio_option": { + "type": "radio_buttons", + "selected_option": { + "text": {"type": "plain_text", "text": "Disagree", "emoji": True}, + "value": "4", + }, + } + }, + "alphabet": { + "select_menu_options": { + "type": "static_select", + "selected_option": { + "text": {"type": "plain_text", "text": "BBBB", "emoji": True}, + "value": "B", + }, + } + }, + "time_picker": {"pick_time": {"type": "timepicker", "selected_time": "13:37"}}, + "url": {"provide_url": {"type": "url_text_input"}}, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7122776759568/veQdwQLRKk9bWJ2KPW2U3q9E", + "actions": [ + { + "type": "static_select", + "action_id": "select_menu_options", + "block_id": "alphabet", + "selected_option": {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + "action_ts": "1715360381.098191", + } + ], +} diff --git a/tests/models/example_payloads/block_action_select_conversation.py b/tests/models/example_payloads/block_action_select_conversation.py new file mode 100644 index 00000000..0ee82640 --- /dev/null +++ b/tests/models/example_payloads/block_action_select_conversation.py @@ -0,0 +1,262 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715372397.467649", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7100885080627.842445099040.7ceb5e6cc2beff499bfd9a4b7bd239a3", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715372397.467649", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "ewnQn", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "sALgV", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "DCpSD"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "multi_select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + { + "type": "section", + "block_id": "+06RN", + "text": {"type": "mrkdwn", "text": "Pick a number", "verbatim": False}, + "accessory": { + "type": "overflow", + "action_id": "pick_overflow_option", + "options": [ + {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + {"text": {"type": "plain_text", "text": "Two", "emoji": True}, "value": "two"}, + {"text": {"type": "plain_text", "text": "Three", "emoji": True}, "value": "three"}, + ], + }, + }, + { + "type": "input", + "block_id": "feelings", + "label": {"type": "plain_text", "text": "Your feelings", "emoji": True}, + "hint": {"type": "plain_text", "text": "Be honest...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "provide_feelings", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + { + "type": "input", + "block_id": "radio_demonstration", + "label": {"type": "plain_text", "text": "You enjoy this selection of input elements", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "select_radio_option", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "alphabet", + "label": { + "type": "plain_text", + "text": "What is your favorite character in the alphabet?", + "emoji": True, + }, + "hint": {"type": "plain_text", "text": "Choose wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "AAAA", "emoji": True}, "value": "A"}, + {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + {"text": {"type": "plain_text", "text": "CCCC", "emoji": True}, "value": "C"}, + ], + }, + }, + { + "type": "input", + "block_id": "time_picker", + "label": {"type": "plain_text", "text": "Pick a time", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your time wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "timepicker", "action_id": "pick_time", "initial_time": "13:37"}, + }, + { + "type": "input", + "block_id": "url", + "label": {"type": "plain_text", "text": "Provide a URL", "emoji": True}, + "hint": {"type": "plain_text", "text": "URLs are cool...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "url_text_input", + "action_id": "provide_url", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + { + "type": "input", + "block_id": "channels_select", + "label": {"type": "plain_text", "text": "Select channels", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose channels...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "multi_channels_select", "action_id": "select_channel"}, + }, + { + "type": "input", + "block_id": "conversation_select", + "label": {"type": "plain_text", "text": "Select a conversation", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose a conversation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "conversations_select", "action_id": "select_conversation"}, + }, + {"type": "divider", "block_id": "e8sJ4"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": None}}, + "checkboxes": {"select_options": {"type": "checkboxes", "selected_options": []}}, + "email": {"provide_email": {"type": "email_text_input"}}, + "startrek": {"multi_select_menu_options": {"type": "multi_static_select", "selected_options": []}}, + "number": {"enter_number": {"type": "number_input"}}, + "feelings": {"provide_feelings": {"type": "plain_text_input", "value": None}}, + "radio_demonstration": {"select_radio_option": {"type": "radio_buttons", "selected_option": None}}, + "alphabet": {"select_menu_options": {"type": "static_select", "selected_option": None}}, + "time_picker": {"pick_time": {"type": "timepicker", "selected_time": "13:37"}}, + "url": {"provide_url": {"type": "url_text_input"}}, + "channels_select": {"select_channel": {"type": "multi_channels_select", "selected_channels": []}}, + "conversation_select": { + "select_conversation": {"type": "conversations_select", "selected_conversation": "U038Y1G7745"} + }, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7086441324583/GrvKhhYiNdV5v3GjUQr1vPWP", + "actions": [ + { + "type": "conversations_select", + "action_id": "select_conversation", + "block_id": "conversation_select", + "selected_conversation": "U038Y1G7745", + "action_ts": "1715372402.623343", + } + ], +} diff --git a/tests/models/example_payloads/block_action_timepicker.py b/tests/models/example_payloads/block_action_timepicker.py new file mode 100644 index 00000000..d9ed52b9 --- /dev/null +++ b/tests/models/example_payloads/block_action_timepicker.py @@ -0,0 +1,277 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715360075.165019", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7112678406241.842445099040.8030023e538412945193542a9c80565a", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715360075.165019", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "7wq18", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "hOjar", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "H5Jbh"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "multi_select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + { + "type": "section", + "block_id": "xJwX4", + "text": {"type": "mrkdwn", "text": "Pick a number", "verbatim": False}, + "accessory": { + "type": "overflow", + "action_id": "pick_overflow_option", + "options": [ + {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + {"text": {"type": "plain_text", "text": "Two", "emoji": True}, "value": "two"}, + {"text": {"type": "plain_text", "text": "Three", "emoji": True}, "value": "three"}, + ], + }, + }, + { + "type": "input", + "block_id": "feelings", + "label": {"type": "plain_text", "text": "Your feelings", "emoji": True}, + "hint": {"type": "plain_text", "text": "Be honest...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "provide_feelings", + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + { + "type": "input", + "block_id": "radio_demonstration", + "label": { + "type": "plain_text", + "text": "You enjoy this selection of input elements", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "select_radio_option", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "alphabet", + "label": { + "type": "plain_text", + "text": "What is your favorite character in the alphabet?", + "emoji": True, + }, + "hint": {"type": "plain_text", "text": "Choose wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "AAAA", "emoji": True}, "value": "A"}, + {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + {"text": {"type": "plain_text", "text": "CCCC", "emoji": True}, "value": "C"}, + ], + }, + }, + { + "type": "input", + "block_id": "time_picker", + "label": {"type": "plain_text", "text": "Pick a time", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your time wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "timepicker", "action_id": "pick_time", "initial_time": "13:37"}, + }, + { + "type": "input", + "block_id": "url", + "label": {"type": "plain_text", "text": "Provide a URL", "emoji": True}, + "hint": {"type": "plain_text", "text": "URLs are cool...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "url_text_input", + "action_id": "provide_url", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + {"type": "divider", "block_id": "GuWGz"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-24"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + } + }, + "email": {"provide_email": {"type": "email_text_input", "value": "daan@dv.email"}}, + "startrek": { + "multi_select_menu_options": { + "type": "multi_static_select", + "selected_options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + ], + } + }, + "number": {"enter_number": {"type": "number_input", "value": "55"}}, + "feelings": {"provide_feelings": {"type": "plain_text_input", "value": "I'm great!"}}, + "radio_demonstration": { + "select_radio_option": { + "type": "radio_buttons", + "selected_option": { + "text": {"type": "plain_text", "text": "Disagree", "emoji": True}, + "value": "4", + }, + } + }, + "alphabet": { + "select_menu_options": { + "type": "static_select", + "selected_option": { + "text": {"type": "plain_text", "text": "BBBB", "emoji": True}, + "value": "B", + }, + } + }, + "time_picker": {"pick_time": {"type": "timepicker", "selected_time": "03:00"}}, + "url": {"provide_url": {"type": "url_text_input"}}, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7085453877927/pXZidTamWdGqLnCHkffoOQGQ", + "actions": [ + { + "type": "timepicker", + "action_id": "pick_time", + "block_id": "time_picker", + "selected_time": "03:00", + "initial_time": "13:37", + "action_ts": "1715360581.475910", + } + ], +} diff --git a/tests/models/example_payloads/block_action_url_input.py b/tests/models/example_payloads/block_action_url_input.py new file mode 100644 index 00000000..d74445f9 --- /dev/null +++ b/tests/models/example_payloads/block_action_url_input.py @@ -0,0 +1,276 @@ +payload = { + "type": "block_actions", + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "container": { + "type": "message", + "message_ts": "1715360075.165019", + "channel_id": "CQEUMSV7D", + "is_ephemeral": False, + }, + "trigger_id": "7100031247362.842445099040.afd2daecb0de633a0006afd4eaef8a27", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "CQEUMSV7D", "name": "general"}, + "message": { + "user": "U038Y1G7745", + "type": "message", + "ts": "1715360075.165019", + "bot_id": "B0390TCABQB", + "app_id": "A039QKQ6G1E", + "text": "<@UQEUMSA0K>: Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "team": "TQSD32X16", + "blocks": [ + { + "type": "header", + "block_id": "7wq18", + "text": {"type": "plain_text", "text": "Interactivity :tada:", "emoji": True}, + }, + { + "type": "section", + "block_id": "hOjar", + "text": { + "type": "mrkdwn", + "text": "Hey <@UQEUMSA0K>, you wanna see some interactive goodness? I can show you!", + "verbatim": False, + }, + }, + {"type": "divider", "block_id": "H5Jbh"}, + { + "type": "input", + "block_id": "date_picker", + "label": {"type": "plain_text", "text": "Pick a date", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your date wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "datepicker", "action_id": "pick_date"}, + }, + { + "type": "input", + "block_id": "checkboxes", + "label": {"type": "plain_text", "text": "Select some fruits", "emoji": True}, + "hint": {"type": "plain_text", "text": "The fruits are healthy...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "checkboxes", + "action_id": "select_options", + "options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + {"text": {"type": "plain_text", "text": "red cherry", "emoji": True}, "value": "cherry"}, + ], + }, + }, + { + "type": "input", + "block_id": "email", + "label": {"type": "plain_text", "text": "Provide email address", "emoji": True}, + "hint": {"type": "plain_text", "text": "Email is personal...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "email_text_input", "action_id": "provide_email"}, + }, + { + "type": "input", + "block_id": "startrek", + "label": {"type": "plain_text", "text": "Select favorite Star Trek characters", "emoji": True}, + "hint": {"type": "plain_text", "text": "Next Generation...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "action_id": "multi_select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + {"text": {"type": "plain_text", "text": "Worf", "emoji": True}, "value": "worf"}, + ], + }, + }, + { + "type": "input", + "block_id": "number", + "label": {"type": "plain_text", "text": "What is your favorite number?", "emoji": True}, + "hint": {"type": "plain_text", "text": "42", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "number_input", "action_id": "enter_number", "is_decimal_allowed": False}, + }, + { + "type": "section", + "block_id": "xJwX4", + "text": {"type": "mrkdwn", "text": "Pick a number", "verbatim": False}, + "accessory": { + "type": "overflow", + "action_id": "pick_overflow_option", + "options": [ + {"text": {"type": "plain_text", "text": "One", "emoji": True}, "value": "one"}, + {"text": {"type": "plain_text", "text": "Two", "emoji": True}, "value": "two"}, + {"text": {"type": "plain_text", "text": "Three", "emoji": True}, "value": "three"}, + ], + }, + }, + { + "type": "input", + "block_id": "feelings", + "label": {"type": "plain_text", "text": "Your feelings", "emoji": True}, + "hint": {"type": "plain_text", "text": "Be honest...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "provide_feelings", + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + { + "type": "input", + "block_id": "radio_demonstration", + "label": { + "type": "plain_text", + "text": "You enjoy this selection of input elements", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "select_radio_option", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "alphabet", + "label": { + "type": "plain_text", + "text": "What is your favorite character in the alphabet?", + "emoji": True, + }, + "hint": {"type": "plain_text", "text": "Choose wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "static_select", + "action_id": "select_menu_options", + "options": [ + {"text": {"type": "plain_text", "text": "AAAA", "emoji": True}, "value": "A"}, + {"text": {"type": "plain_text", "text": "BBBB", "emoji": True}, "value": "B"}, + {"text": {"type": "plain_text", "text": "CCCC", "emoji": True}, "value": "C"}, + ], + }, + }, + { + "type": "input", + "block_id": "time_picker", + "label": {"type": "plain_text", "text": "Pick a time", "emoji": True}, + "hint": {"type": "plain_text", "text": "Choose your time wisely...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": {"type": "timepicker", "action_id": "pick_time", "initial_time": "13:37"}, + }, + { + "type": "input", + "block_id": "url", + "label": {"type": "plain_text", "text": "Provide a URL", "emoji": True}, + "hint": {"type": "plain_text", "text": "URLs are cool...", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "url_text_input", + "action_id": "provide_url", + "dispatch_action_config": {"trigger_actions_on": ["on_character_entered"]}, + }, + }, + {"type": "divider", "block_id": "GuWGz"}, + { + "type": "actions", + "block_id": "interactions_confirmation", + "elements": [ + { + "type": "button", + "action_id": "interactions_approve", + "text": {"type": "plain_text", "text": "Yes, please.", "emoji": True}, + "style": "primary", + "value": "UQEUMSA0K", + }, + { + "type": "button", + "action_id": "interactions_deny", + "text": {"type": "plain_text", "text": "No, thank you.", "emoji": True}, + "style": "danger", + "value": "UQEUMSA0K", + }, + ], + }, + ], + }, + "state": { + "values": { + "date_picker": {"pick_date": {"type": "datepicker", "selected_date": "2024-05-24"}}, + "checkboxes": { + "select_options": { + "type": "checkboxes", + "selected_options": [ + {"text": {"type": "mrkdwn", "text": "*juicy apple*", "verbatim": False}, "value": "apple"}, + {"text": {"type": "plain_text", "text": "fresh orange", "emoji": True}, "value": "orange"}, + ], + } + }, + "email": {"provide_email": {"type": "email_text_input", "value": "daan@dv.email"}}, + "startrek": { + "multi_select_menu_options": { + "type": "multi_static_select", + "selected_options": [ + {"text": {"type": "plain_text", "text": "Data", "emoji": True}, "value": "data"}, + {"text": {"type": "plain_text", "text": "Picard", "emoji": True}, "value": "picard"}, + ], + } + }, + "number": {"enter_number": {"type": "number_input", "value": "55"}}, + "feelings": {"provide_feelings": {"type": "plain_text_input", "value": "I'm great!"}}, + "radio_demonstration": { + "select_radio_option": { + "type": "radio_buttons", + "selected_option": { + "text": {"type": "plain_text", "text": "Disagree", "emoji": True}, + "value": "4", + }, + } + }, + "alphabet": { + "select_menu_options": { + "type": "static_select", + "selected_option": { + "text": {"type": "plain_text", "text": "BBBB", "emoji": True}, + "value": "B", + }, + } + }, + "time_picker": {"pick_time": {"type": "timepicker", "selected_time": "03:00"}}, + "url": {"provide_url": {"type": "url_text_input", "value": "https://source.ag"}}, + } + }, + "response_url": "https://hooks.slack.com/actions/TQSD32X16/7085472689175/VXwSrfkgY3GzEqG6wayvCFne", + "actions": [ + { + "type": "url_text_input", + "block_id": "url", + "action_id": "provide_url", + "value": "https://source.ag", + "action_ts": "1715360782.357206", + } + ], +} diff --git a/tests/models/example_payloads/view_submission.py b/tests/models/example_payloads/view_submission.py new file mode 100644 index 00000000..2eaa997c --- /dev/null +++ b/tests/models/example_payloads/view_submission.py @@ -0,0 +1,182 @@ +payload = { + "type": "view_submission", + "team": {"id": "TQSD32X16", "domain": "dandydev"}, + "user": {"id": "UQEUMSA0K", "username": "daan", "name": "daan", "team_id": "TQSD32X16"}, + "api_app_id": "A039QKQ6G1E", + "token": "ZMBw88SmAGYGpwguEEH4bZ84", + "trigger_id": "7098980171730.842445099040.997436e4e2827c9430b8c9c36882b3b4", + "view": { + "id": "V072WURP7LJ", + "team_id": "TQSD32X16", + "type": "modal", + "blocks": [ + { + "type": "section", + "block_id": "x5guW", + "text": { + "type": "plain_text", + "text": ":wave: Hey David!\n\nWe'd love to hear from you how we can make this place the best place you’ve ever worked.", # noqa: E501 + "emoji": True, + }, + }, + {"type": "divider", "block_id": "wdCiM"}, + { + "type": "input", + "block_id": "working_here", + "label": {"type": "plain_text", "text": "You enjoy working here at Pistachio & Co", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "radio_buttons", + "action_id": "working_here_options", + "options": [ + {"text": {"type": "plain_text", "text": "Strongly agree", "emoji": True}, "value": "1"}, + {"text": {"type": "plain_text", "text": "Agree", "emoji": True}, "value": "2"}, + { + "text": {"type": "plain_text", "text": "Neither agree nor disagree", "emoji": True}, + "value": "3", + }, + {"text": {"type": "plain_text", "text": "Disagree", "emoji": True}, "value": "4"}, + {"text": {"type": "plain_text", "text": "Strongly disagree", "emoji": True}, "value": "5"}, + ], + }, + }, + { + "type": "input", + "block_id": "4j/Gd", + "label": { + "type": "plain_text", + "text": "What do you want for our team weekly lunch?", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "multi_static_select", + "placeholder": {"type": "plain_text", "text": "Select your favorites", "emoji": True}, + "options": [ + { + "text": {"type": "plain_text", "text": ":pizza: Pizza", "emoji": True}, + "value": "value-0", + }, + { + "text": {"type": "plain_text", "text": ":fried_shrimp: Thai food", "emoji": True}, + "value": "value-1", + }, + { + "text": {"type": "plain_text", "text": ":desert_island: Hawaiian", "emoji": True}, + "value": "value-2", + }, + { + "text": {"type": "plain_text", "text": ":meat_on_bone: Texas BBQ", "emoji": True}, + "value": "value-3", + }, + { + "text": {"type": "plain_text", "text": ":hamburger: Burger", "emoji": True}, + "value": "value-4", + }, + {"text": {"type": "plain_text", "text": ":taco: Tacos", "emoji": True}, "value": "value-5"}, + { + "text": {"type": "plain_text", "text": ":green_salad: Salad", "emoji": True}, + "value": "value-6", + }, + { + "text": {"type": "plain_text", "text": ":stew: Indian", "emoji": True}, + "value": "value-7", + }, + ], + "action_id": "nfisX", + }, + }, + { + "type": "input", + "block_id": "it2e/", + "label": { + "type": "plain_text", + "text": "What can we do to improve your experience working here?", + "emoji": True, + }, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "Ni5tz", + }, + }, + { + "type": "input", + "block_id": "DVhIO", + "label": {"type": "plain_text", "text": "Anything else you want to tell us?", "emoji": True}, + "optional": True, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + "action_id": "zJn8J", + }, + }, + { + "type": "section", + "block_id": "sLIvA", + "text": {"type": "mrkdwn", "text": "Pick a date for the deadline.", "verbatim": False}, + "accessory": { + "type": "datepicker", + "action_id": "datepicker-action", + "initial_date": "1990-04-28", + "placeholder": {"type": "plain_text", "text": "Select a date", "emoji": True}, + }, + }, + ], + "private_metadata": "", + "callback_id": "", + "state": { + "values": { + "working_here": { + "working_here_options": { + "type": "radio_buttons", + "selected_option": { + "text": {"type": "plain_text", "text": "Agree", "emoji": True}, + "value": "2", + }, + } + }, + "4j/Gd": { + "nfisX": { + "type": "multi_static_select", + "selected_options": [ + { + "text": {"type": "plain_text", "text": ":desert_island: Hawaiian", "emoji": True}, + "value": "value-2", + }, + { + "text": {"type": "plain_text", "text": ":fried_shrimp: Thai food", "emoji": True}, + "value": "value-1", + }, + ], + } + }, + "it2e/": {"Ni5tz": {"type": "plain_text_input", "value": "Pay better"}}, + "DVhIO": {"zJn8J": {"type": "plain_text_input", "value": "Nothing"}}, + "sLIvA": {"datepicker-action": {"type": "datepicker", "selected_date": "1990-04-28"}}, + } + }, + "hash": "1715350171.Gc2dwnJZ", + "title": {"type": "plain_text", "text": "Workplace check-in", "emoji": True}, + "clear_on_close": False, + "notify_on_close": False, + "close": {"type": "plain_text", "text": "Cancel", "emoji": True}, + "submit": {"type": "plain_text", "text": "Submit", "emoji": True}, + "previous_view_id": None, + "root_view_id": "V072WURP7LJ", + "app_id": "A039QKQ6G1E", + "external_id": "", + "app_installed_team_id": "TQSD32X16", + "bot_id": "B0390TCABQB", + }, + "response_urls": [], + "is_enterprise_install": False, + "enterprise": None, +} diff --git a/tests/models/test_block_actions.py b/tests/models/test_block_actions.py new file mode 100644 index 00000000..90c4202f --- /dev/null +++ b/tests/models/test_block_actions.py @@ -0,0 +1,85 @@ +from machine.models.interactive import BlockActionsPayload +from tests.models.example_payloads.block_action_button import payload as button_payload +from tests.models.example_payloads.block_action_button2 import payload as button_payload2 +from tests.models.example_payloads.block_action_button_no_value import payload as button_no_value_payload +from tests.models.example_payloads.block_action_checkboxes import payload as checkboxes_payload +from tests.models.example_payloads.block_action_checkboxes2 import payload as checkboxes_payload2 +from tests.models.example_payloads.block_action_datepicker import payload as datepicker_payload +from tests.models.example_payloads.block_action_datepicker2 import payload as datepicker_payload2 +from tests.models.example_payloads.block_action_in_modal import payload as in_modal_payload +from tests.models.example_payloads.block_action_multi_select import payload as multi_static_select_payload +from tests.models.example_payloads.block_action_multi_select_channel import payload as multi_channels_select_payload +from tests.models.example_payloads.block_action_overflow import payload as overflow_payload +from tests.models.example_payloads.block_action_radio_button import payload as radio_button_payload +from tests.models.example_payloads.block_action_select import payload as static_select_payload +from tests.models.example_payloads.block_action_select_conversation import payload as conversations_select_payload +from tests.models.example_payloads.block_action_timepicker import payload as timepicker_payload +from tests.models.example_payloads.block_action_url_input import payload as url_payload + + +def test_block_action_radio_button(): + validated_radio_button_payload = BlockActionsPayload.model_validate(radio_button_payload) + assert validated_radio_button_payload is not None + + +def test_block_action_button(): + validated_button_payload = BlockActionsPayload.model_validate(button_payload) + assert validated_button_payload is not None + validated_button_payload2 = BlockActionsPayload.model_validate(button_payload2) + assert validated_button_payload2 is not None + validated_button_no_value_payload = BlockActionsPayload.model_validate(button_no_value_payload) + assert validated_button_no_value_payload is not None + + +def test_block_action_checkboxes(): + validated_checkboxes_payload = BlockActionsPayload.model_validate(checkboxes_payload) + assert validated_checkboxes_payload is not None + validated_checkboxes_payload2 = BlockActionsPayload.model_validate(checkboxes_payload2) + assert validated_checkboxes_payload2 is not None + + +def test_block_action_datepicker(): + validated_datepicker_payload = BlockActionsPayload.model_validate(datepicker_payload) + assert validated_datepicker_payload is not None + validated_datepicker_payload2 = BlockActionsPayload.model_validate(datepicker_payload2) + assert validated_datepicker_payload2 is not None + + +def test_block_action_static_select(): + validated_static_select_payload = BlockActionsPayload.model_validate(static_select_payload) + assert validated_static_select_payload is not None + + +def test_block_action_conversations_select(): + validated_conversations_select_payload = BlockActionsPayload.model_validate(conversations_select_payload) + assert validated_conversations_select_payload is not None + + +def test_block_action_multi_static_select(): + validated_multi_static_select_payload = BlockActionsPayload.model_validate(multi_static_select_payload) + assert validated_multi_static_select_payload is not None + + +def test_block_action_multi_channels_select(): + validated_multi_channels_select_payload = BlockActionsPayload.model_validate(multi_channels_select_payload) + assert validated_multi_channels_select_payload is not None + + +def test_block_action_timepicker(): + validated_timepicker_payload = BlockActionsPayload.model_validate(timepicker_payload) + assert validated_timepicker_payload is not None + + +def test_block_action_url_input(): + validated_url_payload = BlockActionsPayload.model_validate(url_payload) + assert validated_url_payload is not None + + +def test_block_action_overflow(): + validated_overflow_payload = BlockActionsPayload.model_validate(overflow_payload) + assert validated_overflow_payload is not None + + +def test_block_action_in_modal(): + validated_in_modal_payload = BlockActionsPayload.model_validate(in_modal_payload) + assert validated_in_modal_payload is not None diff --git a/tests/plugins/test_decorators.py b/tests/plugins/test_decorators.py index bb07ef65..c6430aba 100644 --- a/tests/plugins/test_decorators.py +++ b/tests/plugins/test_decorators.py @@ -5,8 +5,10 @@ from machine.plugins import ee from machine.plugins.decorators import ( + ActionConfig, CommandConfig, MatcherConfig, + action, command, listen_to, on, @@ -89,6 +91,33 @@ def f(msg): return f +@pytest.fixture(scope="module") +def action_f(): + @action("action_1", "block_2") + def f(action_paylaod): + pass + + return f + + +@pytest.fixture(scope="module") +def action_f_regex(): + @action(re.compile(r"action_\d", re.IGNORECASE), re.compile(r"block\d", re.IGNORECASE)) + def f(action_paylaod): + pass + + return f + + +@pytest.fixture(scope="module") +def action_f_no_action_or_block(): + @action(None, None) + def f(action_paylaod): + pass + + return f + + @pytest.fixture(scope="module") def multi_decorator_f(): @respond_to(r"hello-respond", re.IGNORECASE) @@ -215,6 +244,38 @@ def test_on(on_f): assert listeners[0] == on_f +def test_action(action_f): + assert hasattr(action_f, "metadata") + assert hasattr(action_f.metadata, "plugin_actions") + assert hasattr(action_f.metadata.plugin_actions, "actions") + assert action_f.metadata.plugin_actions.actions == [ActionConfig(action_id="action_1", block_id="block_2")] + action_cfg = action_f.metadata.plugin_actions.actions[0] + assert isinstance(action_cfg.action_id, str) + assert isinstance(action_cfg.block_id, str) + + +def test_action_regex(action_f_regex): + assert hasattr(action_f_regex, "metadata") + assert hasattr(action_f_regex.metadata, "plugin_actions") + assert hasattr(action_f_regex.metadata.plugin_actions, "actions") + action_id_pattern = re.compile(r"action_\d", re.IGNORECASE) + block_id_pattern = re.compile(r"block\d", re.IGNORECASE) + assert action_f_regex.metadata.plugin_actions.actions == [ + ActionConfig(action_id=action_id_pattern, block_id=block_id_pattern) + ] + action_cfg = action_f_regex.metadata.plugin_actions.actions[0] + assert isinstance(action_cfg.action_id, re.Pattern) + assert isinstance(action_cfg.block_id, re.Pattern) + + +def test_action_no_action_or_block(): + with pytest.raises(ValueError, match="At least one of action_id or block_id must be provided"): + + @action(None, None) + def f(action_paylaod): + pass + + def test_required_settings_list(required_settings_list_f): assert hasattr(required_settings_list_f, "metadata") assert hasattr(required_settings_list_f.metadata, "required_settings") diff --git a/tests/test_handlers.py b/tests/test_handlers.py deleted file mode 100644 index 3db853ac..00000000 --- a/tests/test_handlers.py +++ /dev/null @@ -1,304 +0,0 @@ -from __future__ import annotations - -import re -from inspect import Signature - -import pytest -from slack_sdk.socket_mode.aiohttp import SocketModeClient -from slack_sdk.socket_mode.request import SocketModeRequest -from structlog.testing import capture_logs - -from machine.clients.slack import SlackClient -from machine.handlers import ( - _check_bot_mention, - create_generic_event_handler, - create_slash_command_handler, - generate_message_matcher, - handle_message, - log_request, -) -from machine.models.core import CommandHandler, MessageHandler, RegisteredActions -from machine.plugins.command import Command -from machine.plugins.message import Message -from machine.storage.backends.base import MachineBaseStorage -from machine.utils.collections import CaseInsensitiveDict -from tests.fake_plugins import FakePlugin - - -@pytest.fixture -def slack_client(mocker): - return mocker.MagicMock(spec=SlackClient) - - -@pytest.fixture -def socket_mode_client(mocker): - return mocker.MagicMock(spec=SocketModeClient) - - -@pytest.fixture -def storage(mocker): - return mocker.MagicMock(spec=MachineBaseStorage) - - -@pytest.fixture -def fake_plugin(mocker, slack_client, storage): - plugin_instance = FakePlugin(slack_client, CaseInsensitiveDict(), storage) - mocker.spy(plugin_instance, "respond_function") - mocker.spy(plugin_instance, "listen_function") - mocker.spy(plugin_instance, "process_function") - mocker.spy(plugin_instance, "command_function") - mocker.spy(plugin_instance, "generator_command_function") - return plugin_instance - - -@pytest.fixture -def plugin_actions(fake_plugin): - respond_fn = fake_plugin.respond_function - listen_fn = fake_plugin.listen_function - process_fn = fake_plugin.process_function - command_fn = fake_plugin.command_function - generator_command_fn = fake_plugin.generator_command_function - plugin_actions = RegisteredActions( - listen_to={ - "TestPlugin.listen_function-hi": MessageHandler( - class_=fake_plugin, - class_name="tests.fake_plugins.FakePlugin", - function=listen_fn, - function_signature=Signature.from_callable(listen_fn), - regex=re.compile("hi", re.IGNORECASE), - handle_message_changed=True, - ) - }, - respond_to={ - "TestPlugin.respond_function-hello": MessageHandler( - class_=fake_plugin, - class_name="tests.fake_plugins.FakePlugin", - function=respond_fn, - function_signature=Signature.from_callable(respond_fn), - regex=re.compile("hello", re.IGNORECASE), - handle_message_changed=False, - ) - }, - process={"some_event": {"TestPlugin.process_function": process_fn}}, - command={ - "/test": CommandHandler( - class_=fake_plugin, - class_name="tests.fake_plugins.FakePlugin", - function=command_fn, - function_signature=Signature.from_callable(command_fn), - command="/test", - is_generator=False, - ), - "/test-generator": CommandHandler( - class_=fake_plugin, - class_name="tests.fake_plugins.FakePlugin", - function=generator_command_fn, - function_signature=Signature.from_callable(generator_command_fn), - command="/test-generator", - is_generator=True, - ), - }, - ) - return plugin_actions - - -@pytest.fixture -def message_matcher(): - return generate_message_matcher({}) - - -def _gen_msg_event(text: str, channel_type: str = "channel") -> dict[str, str]: - return {"type": "message", "text": text, "channel_type": channel_type, "user": "user1"} - - -def test_generate_message_matcher(): - no_aliases_settings = {} - one_alias_settings = {"ALIASES": "!"} - two_aliases_settings = {"ALIASES": "!,$"} - assert generate_message_matcher(no_aliases_settings) == re.compile( - r"^(?:<@(?P\w+)>:?|(?P\w+):) ?(?P.*)$", re.DOTALL - ) - assert generate_message_matcher(one_alias_settings) == re.compile( - r"^(?:<@(?P\w+)>:?|(?P\w+):|(?P!)) ?(?P.*)$", re.DOTALL - ) - assert generate_message_matcher(two_aliases_settings) == re.compile( - rf"^(?:<@(?P\w+)>:?|(?P\w+):|(?P!|{re.escape('$')})) ?(?P.*)$", re.DOTALL - ) - - -def test_check_bot_mention(): - bot_name = "superbot" - bot_id = "123" - message_matcher = generate_message_matcher({"ALIASES": "!,$"}) - - normal_msg_event = _gen_msg_event("hi") - event = _check_bot_mention(normal_msg_event, bot_name, bot_id, message_matcher) - assert event is None - - mention_msg_event = _gen_msg_event("<@123> hi") - event = _check_bot_mention(mention_msg_event, bot_name, bot_id, message_matcher) - assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} - - mention_msg_event_username = _gen_msg_event("superbot: hi") - event = _check_bot_mention(mention_msg_event_username, bot_name, bot_id, message_matcher) - assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} - - mention_msg_event_group = _gen_msg_event("<@123> hi", channel_type="group") - event = _check_bot_mention(mention_msg_event_group, bot_name, bot_id, message_matcher) - assert event == {"text": "hi", "channel_type": "group", "type": "message", "user": "user1"} - - mention_msg_event_other_user = _gen_msg_event("<@456> hi") - event = _check_bot_mention(mention_msg_event_other_user, bot_name, bot_id, message_matcher) - assert event is None - - mention_msg_event_dm = _gen_msg_event("hi", channel_type="im") - event = _check_bot_mention(mention_msg_event_dm, bot_name, bot_id, message_matcher) - assert event == {"text": "hi", "channel_type": "im", "type": "message", "user": "user1"} - - mention_msg_event_dm_with_user = _gen_msg_event("<@123> hi", channel_type="im") - event = _check_bot_mention(mention_msg_event_dm_with_user, bot_name, bot_id, message_matcher) - assert event == {"text": "hi", "channel_type": "im", "type": "message", "user": "user1"} - - mention_msg_event_alias_1 = _gen_msg_event("!hi") - event = _check_bot_mention(mention_msg_event_alias_1, bot_name, bot_id, message_matcher) - assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} - - mention_msg_event_alias_2 = _gen_msg_event("$hi") - event = _check_bot_mention(mention_msg_event_alias_2, bot_name, bot_id, message_matcher) - assert event == {"text": "hi", "channel_type": "channel", "type": "message", "user": "user1"} - - mention_msg_event_wrong_alias = _gen_msg_event("?hi") - event = _check_bot_mention(mention_msg_event_wrong_alias, bot_name, bot_id, message_matcher) - assert event is None - - -def _assert_message(args, text): - # called with 1 positional arg and 0 kw args - assert len(args[0]) == 1 - assert len(args[1]) == 0 - # assert called with Message - assert isinstance(args[0][0], Message) - # assert message equals expected text - assert args[0][0].text == text - - -@pytest.mark.asyncio -async def test_handle_message_listen_to(plugin_actions, fake_plugin, slack_client, message_matcher): - bot_name = "superbot" - bot_id = "123" - msg_event = _gen_msg_event("hi") - - await handle_message(msg_event, bot_name, bot_id, plugin_actions, message_matcher, slack_client, True) - assert fake_plugin.listen_function.call_count == 1 - assert fake_plugin.respond_function.call_count == 0 - args = fake_plugin.listen_function.call_args - _assert_message(args, "hi") - - -@pytest.mark.asyncio -async def test_handle_message_respond_to(plugin_actions, fake_plugin, slack_client, message_matcher): - bot_name = "superbot" - bot_id = "123" - msg_event = _gen_msg_event("<@123> hello") - await handle_message(msg_event, bot_name, bot_id, plugin_actions, message_matcher, slack_client, True) - assert fake_plugin.respond_function.call_count == 1 - assert fake_plugin.listen_function.call_count == 0 - args = fake_plugin.respond_function.call_args - _assert_message(args, "hello") - - -@pytest.mark.asyncio -async def test_handle_message_changed(plugin_actions, fake_plugin, slack_client, message_matcher): - bot_name = "superbot" - bot_id = "123" - msg_event = { - "type": "message", - "subtype": "message_changed", - "message": { - "text": "hi", - "user": "user1", - }, - "channel_type": "channel", - "channel": "C123", - } - await handle_message(msg_event, bot_name, bot_id, plugin_actions, message_matcher, slack_client, True) - assert fake_plugin.respond_function.call_count == 0 - assert fake_plugin.listen_function.call_count == 1 - args = fake_plugin.listen_function.call_args - _assert_message(args, "hi") - - -def _gen_event_request(event_type: str): - return SocketModeRequest(type="events_api", envelope_id="x", payload={"event": {"type": event_type, "foo": "bar"}}) - - -@pytest.mark.asyncio -async def test_create_generic_event_handler(plugin_actions, fake_plugin, socket_mode_client): - handler = create_generic_event_handler(plugin_actions) - await handler(socket_mode_client, _gen_event_request("other_event")) - assert fake_plugin.process_function.call_count == 0 - await handler(socket_mode_client, _gen_event_request("some_event")) - assert fake_plugin.process_function.call_count == 1 - args = fake_plugin.process_function.call_args - assert len(args[0]) == 1 - assert len(args[1]) == 0 - assert args[0][0] == {"type": "some_event", "foo": "bar"} - - -def _gen_command_request(command: str, text: str): - payload = {"command": command, "text": text, "response_url": "https://my.webhook.com"} - return SocketModeRequest(type="slash_commands", envelope_id="x", payload=payload) - - -def assert_command(args, command, text): - # called with 1 positional arg and 0 kw args - assert len(args[0]) == 1 - assert len(args[1]) == 0 - # assert called with Command - assert isinstance(args[0][0], Command) - # assert command equals expected command - assert args[0][0].command == command - # assert command text equals expected text - assert args[0][0].text == text - - -@pytest.mark.asyncio -async def test_create_slash_command_handler(plugin_actions, fake_plugin, socket_mode_client, slack_client): - handler = create_slash_command_handler(plugin_actions, slack_client) - await handler(socket_mode_client, _gen_command_request("/test", "foo")) - assert fake_plugin.command_function.call_count == 1 - args = fake_plugin.command_function.call_args - assert_command(args, "/test", "foo") - socket_mode_client.send_socket_mode_response.assert_called_once() - resp = socket_mode_client.send_socket_mode_response.call_args.args[0] - assert resp.envelope_id == "x" - assert resp.payload is None - assert fake_plugin.generator_command_function.call_count == 0 - - -@pytest.mark.asyncio -async def test_create_slash_command_handler_generator(plugin_actions, fake_plugin, socket_mode_client, slack_client): - handler = create_slash_command_handler(plugin_actions, slack_client) - await handler(socket_mode_client, _gen_command_request("/test-generator", "bar")) - assert fake_plugin.generator_command_function.call_count == 1 - args = fake_plugin.generator_command_function.call_args - assert_command(args, "/test-generator", "bar") - socket_mode_client.send_socket_mode_response.assert_called_once() - resp = socket_mode_client.send_socket_mode_response.call_args.args[0] - assert resp.envelope_id == "x" - # SocketModeResponse will transform a string into a dict with `text` as only key - assert resp.payload == {"text": "hello"} - assert fake_plugin.command_function.call_count == 0 - - -@pytest.mark.asyncio -async def test_request_logger_handler(socket_mode_client): - with capture_logs() as cap_logs: - await log_request(socket_mode_client, _gen_command_request("/test", "foo")) - log_event = cap_logs[0] - assert log_event["event"] == "Request received" - assert log_event["type"] == "slash_commands" - assert log_event["request"] == { - "envelope_id": "x", - "payload": {"command": "/test", "text": "foo", "response_url": "https://my.webhook.com"}, - } diff --git a/tests/test_plugin_registration.py b/tests/test_plugin_registration.py index 3e141bbf..e30277a4 100644 --- a/tests/test_plugin_registration.py +++ b/tests/test_plugin_registration.py @@ -4,7 +4,7 @@ from machine import Machine from machine.clients.slack import SlackClient -from machine.models.core import RegisteredActions +from machine.models.core import BlockActionHandler, CommandHandler, MessageHandler, RegisteredActions from machine.plugins.decorators import required_settings from machine.utils.collections import CaseInsensitiveDict from machine.utils.logging import configure_logging @@ -55,12 +55,14 @@ async def test_load_and_register_plugins(settings, slack_client): # Test registration of respond_to actions respond_to_key = "tests.fake_plugins.FakePlugin.respond_function-hello" assert respond_to_key in actions.respond_to + assert isinstance(actions.respond_to[respond_to_key], MessageHandler) assert actions.respond_to[respond_to_key].class_name == "tests.fake_plugins.FakePlugin" assert actions.respond_to[respond_to_key].regex == re.compile("hello", re.IGNORECASE) # Test registration of listen_to actions listen_to_key = "tests.fake_plugins.FakePlugin.listen_function-hi" assert listen_to_key in actions.listen_to + assert isinstance(actions.listen_to[listen_to_key], MessageHandler) assert actions.listen_to[listen_to_key].class_name == "tests.fake_plugins.FakePlugin" assert actions.listen_to[listen_to_key].regex == re.compile("hi", re.IGNORECASE) @@ -69,6 +71,32 @@ async def test_load_and_register_plugins(settings, slack_client): assert "some_event" in actions.process assert process_key in actions.process["some_event"] + # Test registration of command actions + command_key = "/test" + assert command_key in actions.command + assert isinstance(actions.command[command_key], CommandHandler) + assert actions.command[command_key].class_name == "tests.fake_plugins.FakePlugin" + assert actions.command[command_key].command == "/test" + assert not actions.command[command_key].is_generator + + # Test registration of generator command actions + generator_command_key = "/test-generator" + assert generator_command_key in actions.command + assert isinstance(actions.command[generator_command_key], CommandHandler) + assert actions.command[generator_command_key].class_name == "tests.fake_plugins.FakePlugin" + assert actions.command[generator_command_key].command == "/test-generator" + assert actions.command[generator_command_key].is_generator + + # Test registration of block actions + block_action_key = "tests.fake_plugins.FakePlugin.block_action_function-my_action.*-my_block" + assert block_action_key in actions.block_actions + assert isinstance(actions.block_actions[block_action_key], BlockActionHandler) + assert actions.block_actions[block_action_key].class_name == "tests.fake_plugins.FakePlugin" + assert isinstance(actions.block_actions[block_action_key].action_id_matcher, re.Pattern) + assert actions.block_actions[block_action_key].action_id_matcher == re.compile("my_action.*", re.IGNORECASE) + assert isinstance(actions.block_actions[block_action_key].block_id_matcher, str) + assert actions.block_actions[block_action_key].block_id_matcher == "my_block" + @pytest.mark.asyncio async def test_plugin_storage_fq_plugin_name(settings, slack_client):