From 1c5fa85aeffb47b4bd4918d9c3307c29d2a10069 Mon Sep 17 00:00:00 2001 From: Daan Debie Date: Fri, 31 May 2024 20:57:15 +0200 Subject: [PATCH] feat: handle view submission and closed events --- machine/handlers/interactive_handler.py | 99 +++++++++++- machine/plugins/modals.py | 18 +++ tests/handlers/conftest.py | 47 ++++-- tests/handlers/requests.py | 173 +++++++++++++++++++++ tests/handlers/test_command_handler.py | 2 +- tests/handlers/test_interactive_handler.py | 116 +++++++------- tests/handlers/test_logging.py | 2 +- 7 files changed, 381 insertions(+), 76 deletions(-) create mode 100644 machine/plugins/modals.py create mode 100644 tests/handlers/requests.py diff --git a/machine/handlers/interactive_handler.py b/machine/handlers/interactive_handler.py index bfd8f4f9..7d2a1c10 100644 --- a/machine/handlers/interactive_handler.py +++ b/machine/handlers/interactive_handler.py @@ -1,9 +1,11 @@ from __future__ import annotations import asyncio +import contextlib import re -from typing import Awaitable, Callable, Union +from typing import AsyncGenerator, Awaitable, Callable, Union, cast +from slack_sdk.models.views import View 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 @@ -12,8 +14,15 @@ 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.models.interactive import ( + Action, + BlockActionsPayload, + InteractivePayload, + ViewClosedPayload, + ViewSubmissionPayload, +) from machine.plugins.block_action import BlockAction +from machine.plugins.modals import ModalClosure, ModalSubmission logger = get_logger(__name__) @@ -25,13 +34,19 @@ def create_interactive_handler( 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": + # Acknowledge the request + response = SocketModeResponse(envelope_id=request.envelope_id) + await client.send_socket_mode_response(response) await handle_block_actions(parsed_payload, plugin_actions, slack_client) + if parsed_payload.type == "view_submission": + await handle_view_submission(parsed_payload, request.envelope_id, client, plugin_actions, slack_client) + if parsed_payload.type == "view_closed": + # Acknowledge the request + response = SocketModeResponse(envelope_id=request.envelope_id) + await client.send_socket_mode_response(response) + await handle_view_closed(parsed_payload, plugin_actions, slack_client) return handle_interactive_request @@ -64,6 +79,70 @@ async def handle_block_actions( await asyncio.gather(*handler_funcs) +async def handle_view_submission( + payload: ViewSubmissionPayload, + envelope_id: str, + socket_mode_client: AsyncBaseSocketModeClient, + plugin_actions: RegisteredActions, + slack_client: SlackClient, +) -> None: + handler_funcs = [] + modal_submission_obj = _gen_modal_submission(payload, slack_client) + for handler in plugin_actions.modal.values(): + if _matches(handler.callback_id_matcher, payload.view.callback_id): + if "logger" in handler.function_signature.parameters: + view_submission_logger = create_scoped_logger( + handler.class_name, + handler.function.__name__, + user_id=payload.user.id, + user_name=payload.user.name, + ) + extra_args = {"logger": view_submission_logger} + else: + extra_args = {} + # Check if the handler is a generator. In this case we have an immediate response we can send back + if handler.is_generator: + gen_fn = cast(Callable[..., AsyncGenerator[Union[dict, View], None]], handler.function) + logger.debug("Modal submission handler is generator, returning immediate ack") + gen = gen_fn(modal_submission_obj, **extra_args) + # return immediate reponse + response = await gen.__anext__() + ack_response = SocketModeResponse(envelope_id=envelope_id, payload=response) + await socket_mode_client.send_socket_mode_response(ack_response) + # Now run the rest of the function + with contextlib.suppress(StopAsyncIteration): + await gen.__anext__() + else: + logger.debug("Modal submission is regular async function") + ack_response = SocketModeResponse(envelope_id=envelope_id) + await socket_mode_client.send_socket_mode_response(ack_response) + handler_funcs.append(handler.function(modal_submission_obj, **extra_args)) + await asyncio.gather(*handler_funcs) + + +async def handle_view_closed( + payload: ViewClosedPayload, + plugin_actions: RegisteredActions, + slack_client: SlackClient, +) -> None: + handler_funcs = [] + modal_submission_obj = _gen_modal_closure(payload, slack_client) + for handler in plugin_actions.modal_closed.values(): + if _matches(handler.callback_id_matcher, payload.view.callback_id): + if "logger" in handler.function_signature.parameters: + view_closure_logger = create_scoped_logger( + handler.class_name, + handler.function.__name__, + user_id=payload.user.id, + user_name=payload.user.name, + ) + extra_args = {"logger": view_closure_logger} + else: + extra_args = {} + handler_funcs.append(handler.function(modal_submission_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 @@ -74,3 +153,11 @@ def _matches(matcher: Union[re.Pattern[str], str, None], input_: str) -> bool: def _gen_block_action(payload: BlockActionsPayload, triggered_action: Action, slack_client: SlackClient) -> BlockAction: return BlockAction(slack_client, payload, triggered_action) + + +def _gen_modal_submission(payload: ViewSubmissionPayload, slack_client: SlackClient) -> ModalSubmission: + return ModalSubmission(slack_client, payload) + + +def _gen_modal_closure(payload: ViewClosedPayload, slack_client: SlackClient) -> ModalClosure: + return ModalClosure(slack_client, payload) diff --git a/machine/plugins/modals.py b/machine/plugins/modals.py new file mode 100644 index 00000000..e3c88258 --- /dev/null +++ b/machine/plugins/modals.py @@ -0,0 +1,18 @@ +from machine.clients.slack import SlackClient +from machine.models.interactive import ViewClosedPayload, ViewSubmissionPayload + + +class ModalSubmission: + payload: ViewSubmissionPayload + + def __init__(self, client: SlackClient, payload: ViewSubmissionPayload): + self._client = client + self.payload = payload + + +class ModalClosure: + payload: ViewClosedPayload + + def __init__(self, client: SlackClient, payload: ViewClosedPayload): + self._client = client + self.payload = payload diff --git a/tests/handlers/conftest.py b/tests/handlers/conftest.py index b5922eec..9d70192f 100644 --- a/tests/handlers/conftest.py +++ b/tests/handlers/conftest.py @@ -3,10 +3,9 @@ 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.models.core import BlockActionHandler, CommandHandler, MessageHandler, ModalHandler, RegisteredActions from machine.storage import MachineBaseStorage from machine.utils.collections import CaseInsensitiveDict from tests.fake_plugins import FakePlugin @@ -36,6 +35,9 @@ def fake_plugin(mocker, slack_client, storage): mocker.spy(plugin_instance, "command_function") mocker.spy(plugin_instance, "generator_command_function") mocker.spy(plugin_instance, "block_action_function") + mocker.spy(plugin_instance, "modal_function") + mocker.spy(plugin_instance, "generator_modal_function") + mocker.spy(plugin_instance, "modal_closed_function") return plugin_instance @@ -46,6 +48,10 @@ def plugin_actions(fake_plugin): process_fn = fake_plugin.process_function command_fn = fake_plugin.command_function generator_command_fn = fake_plugin.generator_command_function + block_action_fn = fake_plugin.block_action_function + modal_fn = fake_plugin.modal_function + generator_modal_fn = fake_plugin.generator_modal_function + modal_closed_fn = fake_plugin.modal_closed_function plugin_actions = RegisteredActions( listen_to={ "TestPlugin.listen_function-hi": MessageHandler( @@ -90,16 +96,39 @@ def plugin_actions(fake_plugin): "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), + function=block_action_fn, + function_signature=Signature.from_callable(block_action_fn), action_id_matcher=re.compile("my_action.*", re.IGNORECASE), block_id_matcher="my_block", ) }, + modal={ + "TestPlugin.modal_function-my_modal.*": ModalHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=modal_fn, + function_signature=Signature.from_callable(modal_fn), + callback_id_matcher=re.compile("my_modal.*", re.IGNORECASE), + is_generator=False, + ), + "TestPlugin.generator_modal_function-my_generator_modal": ModalHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=generator_modal_fn, + function_signature=Signature.from_callable(generator_modal_fn), + callback_id_matcher="my_generator_modal", + is_generator=True, + ), + }, + modal_closed={ + "TestPlugin.modal_closed_function-my_modal_2": ModalHandler( + class_=fake_plugin, + class_name="tests.fake_plugins.FakePlugin", + function=modal_closed_fn, + function_signature=Signature.from_callable(modal_closed_fn), + callback_id_matcher="my_modal_2", + is_generator=False, + ) + }, ) 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/requests.py b/tests/handlers/requests.py new file mode 100644 index 00000000..7f587755 --- /dev/null +++ b/tests/handlers/requests.py @@ -0,0 +1,173 @@ +from slack_sdk.socket_mode.request import SocketModeRequest + + +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 _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 _gen_view_submission_request(callback_id): + payload = { + "type": "view_submission", + "team": {"id": "T12345678", "domain": "workspace-domain"}, + "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"}, + "api_app_id": "A12345678", + "token": "verification_token", + "trigger_id": "1234567890.123456", + "enterprise": None, + "is_enterprise_install": False, + "response_urls": [], + "view": { + "id": "V1234567890", + "team_id": "T12345678", + "type": "modal", + "blocks": [ + { + "type": "header", + "block_id": "k3dNV", + "text": {"type": "plain_text", "text": "What do you want?", "emoji": True}, + }, + { + "type": "input", + "block_id": "modal_input", + "label": {"type": "plain_text", "text": "Give your opinion", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "opinion", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + ], + "private_metadata": "", + "callback_id": callback_id, + "state": {"values": {"modal_input": {"opinion": {"type": "plain_text_input", "value": "YYippieee"}}}}, + "hash": "1717180005.lGLYVzOE", + "title": {"type": "plain_text", "text": "My App", "emoji": True}, + "clear_on_close": False, + "notify_on_close": True, + "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True}, + "submit": {"type": "plain_text", "text": ":rocket: Submit", "emoji": True}, + "previous_view_id": None, + "root_view_id": "V1234567890", + "app_id": "A12345678", + "external_id": "", + "app_installed_team_id": "T12345678", + "bot_id": "B1234567890", + }, + } + return SocketModeRequest(type="interactive", envelope_id="x", payload=payload) + + +def _gen_view_closed_request(callback_id): + payload = { + "type": "view_closed", + "team": {"id": "T12345678", "domain": "workspace-domain"}, + "user": {"id": "U12345678", "username": "user1", "name": "user1", "team_id": "T12345678"}, + "api_app_id": "A12345678", + "token": "verification_token", + "enterprise": None, + "is_enterprise_install": False, + "is_cleared": True, + "view": { + "id": "V1234567890", + "team_id": "T12345678", + "type": "modal", + "blocks": [ + { + "type": "header", + "block_id": "k3dNV", + "text": {"type": "plain_text", "text": "What do you want?", "emoji": True}, + }, + { + "type": "input", + "block_id": "modal_input", + "label": {"type": "plain_text", "text": "Give your opinion", "emoji": True}, + "optional": False, + "dispatch_action": False, + "element": { + "type": "plain_text_input", + "action_id": "opinion", + "multiline": True, + "dispatch_action_config": {"trigger_actions_on": ["on_enter_pressed"]}, + }, + }, + ], + "private_metadata": "", + "callback_id": callback_id, + "state": {"values": {"modal_input": {"opinion": {"type": "plain_text_input", "value": "YYippieee"}}}}, + "hash": "1717180005.lGLYVzOE", + "title": {"type": "plain_text", "text": "My App", "emoji": True}, + "clear_on_close": False, + "notify_on_close": True, + "close": {"type": "plain_text", "text": ":cry: Cancel", "emoji": True}, + "submit": {"type": "plain_text", "text": ":rocket: Submit", "emoji": True}, + "previous_view_id": None, + "root_view_id": "V1234567890", + "app_id": "A12345678", + "external_id": "", + "app_installed_team_id": "T12345678", + "bot_id": "B1234567890", + }, + } + return SocketModeRequest(type="interactive", envelope_id="x", payload=payload) diff --git a/tests/handlers/test_command_handler.py b/tests/handlers/test_command_handler.py index 5db510d0..4becd130 100644 --- a/tests/handlers/test_command_handler.py +++ b/tests/handlers/test_command_handler.py @@ -2,7 +2,7 @@ from machine.handlers import create_slash_command_handler from machine.plugins.command import Command -from tests.handlers.conftest import gen_command_request +from tests.handlers.requests import gen_command_request def _assert_command(args, command, text): diff --git a/tests/handlers/test_interactive_handler.py b/tests/handlers/test_interactive_handler.py index beb7c6d0..c1df00ec 100644 --- a/tests/handlers/test_interactive_handler.py +++ b/tests/handlers/test_interactive_handler.py @@ -1,68 +1,11 @@ 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) +from machine.plugins.modals import ModalClosure, ModalSubmission +from tests.handlers.requests import _gen_block_action_request, _gen_view_closed_request, _gen_view_submission_request def test_matches(): @@ -89,3 +32,58 @@ async def test_create_interactive_handler_for_block_actions( resp = socket_mode_client.send_socket_mode_response.call_args.args[0] assert resp.envelope_id == "x" assert resp.payload is None + + +@pytest.mark.asyncio +async def test_create_interactive_handler_for_view_submission( + plugin_actions, fake_plugin, socket_mode_client, slack_client +): + handler = create_interactive_handler(plugin_actions, slack_client) + request = _gen_view_submission_request("my_modal_1") + await handler(socket_mode_client, request) + assert fake_plugin.modal_function.call_count == 1 + args = fake_plugin.modal_function.call_args + assert isinstance(args[0][0], ModalSubmission) + assert args[0][0].payload.view.callback_id == "my_modal_1" + 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_modal_function.call_count == 0 + + +@pytest.mark.asyncio +async def test_create_interactive_handler_for_view_submission_generator( + plugin_actions, fake_plugin, socket_mode_client, slack_client +): + handler = create_interactive_handler(plugin_actions, slack_client) + request = _gen_view_submission_request("my_generator_modal") + await handler(socket_mode_client, request) + assert fake_plugin.generator_modal_function.call_count == 1 + args = fake_plugin.generator_modal_function.call_args + assert isinstance(args[0][0], ModalSubmission) + assert args[0][0].payload.view.callback_id == "my_generator_modal" + 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 == {"text": "hello"} + assert fake_plugin.modal_function.call_count == 0 + + +@pytest.mark.asyncio +async def test_create_interactive_handler_for_view_closed( + plugin_actions, fake_plugin, socket_mode_client, slack_client +): + handler = create_interactive_handler(plugin_actions, slack_client) + request = _gen_view_closed_request("my_modal_2") + await handler(socket_mode_client, request) + assert fake_plugin.modal_closed_function.call_count == 1 + args = fake_plugin.modal_closed_function.call_args + assert isinstance(args[0][0], ModalClosure) + assert args[0][0].payload.view.callback_id == "my_modal_2" + 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.modal_function.call_count == 0 + assert fake_plugin.generator_modal_function.call_count == 0 diff --git a/tests/handlers/test_logging.py b/tests/handlers/test_logging.py index 3b8ae886..4a33317d 100644 --- a/tests/handlers/test_logging.py +++ b/tests/handlers/test_logging.py @@ -2,7 +2,7 @@ from structlog.testing import capture_logs from machine.handlers import log_request -from tests.handlers.conftest import gen_command_request +from tests.handlers.requests import gen_command_request @pytest.mark.asyncio