Skip to content

Commit

Permalink
feat: handle view submission and closed events
Browse files Browse the repository at this point in the history
  • Loading branch information
DonDebonair committed May 31, 2024
1 parent 3bdc6f3 commit 1c5fa85
Show file tree
Hide file tree
Showing 7 changed files with 381 additions and 76 deletions.
99 changes: 93 additions & 6 deletions machine/handlers/interactive_handler.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__)

Expand All @@ -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

Expand Down Expand Up @@ -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(

Check warning on line 94 in machine/handlers/interactive_handler.py

View check run for this annotation

Codecov / codecov/patch

machine/handlers/interactive_handler.py#L94

Added line #L94 was not covered by tests
handler.class_name,
handler.function.__name__,
user_id=payload.user.id,
user_name=payload.user.name,
)
extra_args = {"logger": view_submission_logger}

Check warning on line 100 in machine/handlers/interactive_handler.py

View check run for this annotation

Codecov / codecov/patch

machine/handlers/interactive_handler.py#L100

Added line #L100 was not covered by tests
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(

Check warning on line 133 in machine/handlers/interactive_handler.py

View check run for this annotation

Codecov / codecov/patch

machine/handlers/interactive_handler.py#L133

Added line #L133 was not covered by tests
handler.class_name,
handler.function.__name__,
user_id=payload.user.id,
user_name=payload.user.name,
)
extra_args = {"logger": view_closure_logger}

Check warning on line 139 in machine/handlers/interactive_handler.py

View check run for this annotation

Codecov / codecov/patch

machine/handlers/interactive_handler.py#L139

Added line #L139 was not covered by tests
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
Expand All @@ -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)
18 changes: 18 additions & 0 deletions machine/plugins/modals.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 38 additions & 9 deletions tests/handlers/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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(
Expand Down Expand Up @@ -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)
Loading

0 comments on commit 1c5fa85

Please sign in to comment.