From 370cddff34807123560d0b99b1d2d92f0f6721b5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 25 Jan 2024 11:40:53 -0500 Subject: [PATCH 01/16] Add remote function support (#986) * add listener --------- Co-authored-by: Kazuhiro Sera --- slack_bolt/__init__.py | 4 + slack_bolt/app/app.py | 53 ++++ slack_bolt/app/async_app.py | 54 ++++ slack_bolt/context/async_context.py | 50 ++++ slack_bolt/context/base_context.py | 22 +- slack_bolt/context/complete/__init__.py | 6 + slack_bolt/context/complete/async_complete.py | 34 +++ slack_bolt/context/complete/complete.py | 34 +++ slack_bolt/context/context.py | 50 ++++ slack_bolt/context/fail/__init__.py | 6 + slack_bolt/context/fail/async_fail.py | 34 +++ slack_bolt/context/fail/fail.py | 34 +++ slack_bolt/kwargs_injection/args.py | 10 + slack_bolt/kwargs_injection/async_args.py | 10 + slack_bolt/kwargs_injection/async_utils.py | 2 + slack_bolt/kwargs_injection/utils.py | 2 + slack_bolt/listener_matcher/builtins.py | 12 + slack_bolt/logger/messages.py | 23 +- slack_bolt/middleware/__init__.py | 3 + slack_bolt/middleware/async_builtins.py | 2 + .../attaching_function_token/__init__.py | 5 + .../async_attaching_function_token.py | 20 ++ .../attaching_function_token.py | 20 ++ slack_bolt/request/async_internals.py | 12 + slack_bolt/request/internals.py | 35 +++ slack_bolt/request/payload_utils.py | 4 + tests/mock_web_api_server.py | 8 +- tests/scenario_tests/test_app_decorators.py | 7 + tests/scenario_tests/test_function.py | 231 +++++++++++++++++ tests/scenario_tests_async/test_function.py | 239 ++++++++++++++++++ tests/slack_bolt/context/test_complete.py | 32 +++ tests/slack_bolt/context/test_fail.py | 32 +++ .../slack_bolt/kwargs_injection/test_args.py | 2 + .../logger/test_unmatched_suggestions.py | 71 ++++++ tests/slack_bolt/request/test_internals.py | 50 ++++ .../context/test_async_complete.py | 38 +++ .../context/test_async_fail.py | 38 +++ .../kwargs_injection/test_async_args.py | 2 + .../logger/test_unmatched_suggestions.py | 71 ++++++ 39 files changed, 1358 insertions(+), 4 deletions(-) create mode 100644 slack_bolt/context/complete/__init__.py create mode 100644 slack_bolt/context/complete/async_complete.py create mode 100644 slack_bolt/context/complete/complete.py create mode 100644 slack_bolt/context/fail/__init__.py create mode 100644 slack_bolt/context/fail/async_fail.py create mode 100644 slack_bolt/context/fail/fail.py create mode 100644 slack_bolt/middleware/attaching_function_token/__init__.py create mode 100644 slack_bolt/middleware/attaching_function_token/async_attaching_function_token.py create mode 100644 slack_bolt/middleware/attaching_function_token/attaching_function_token.py create mode 100644 tests/scenario_tests/test_function.py create mode 100644 tests/scenario_tests_async/test_function.py create mode 100644 tests/slack_bolt/context/test_complete.py create mode 100644 tests/slack_bolt/context/test_fail.py create mode 100644 tests/slack_bolt_async/context/test_async_complete.py create mode 100644 tests/slack_bolt_async/context/test_async_fail.py diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index b1065ca93..d9df66085 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -9,6 +9,8 @@ from .app import App from .context import BoltContext from .context.ack import Ack +from .context.complete import Complete +from .context.fail import Fail from .context.respond import Respond from .context.say import Say from .kwargs_injection import Args @@ -21,6 +23,8 @@ "App", "BoltContext", "Ack", + "Complete", + "Fail", "Respond", "Say", "Args", diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index d01163636..ebada44b2 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -62,6 +62,7 @@ MultiTeamsAuthorization, IgnoringSelfEvents, CustomMiddleware, + AttachingFunctionToken, ) from slack_bolt.middleware.message_listener_matches import MessageListenerMatches from slack_bolt.middleware.middleware_error_handler import ( @@ -111,6 +112,7 @@ def __init__( ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + attaching_function_token_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[OAuthSettings] = None, oauth_flow: Optional[OAuthFlow] = None, @@ -174,6 +176,8 @@ def message_hello(message, say): url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). `UrlVerification` is a built-in middleware that handles url_verification requests that verify the endpoint for Events API in HTTP Mode requests. + attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True). + `AttachingFunctionToken` is a built-in middleware that handles tokens with function requests from Slack. ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) @@ -348,6 +352,7 @@ def message_hello(message, say): ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + attaching_function_token_enabled=attaching_function_token_enabled, ) def _init_middleware_list( @@ -357,6 +362,7 @@ def _init_middleware_list( ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + attaching_function_token_enabled: bool = True, ): if self._init_middleware_list_done: return @@ -407,6 +413,8 @@ def _init_middleware_list( self._middleware_list.append(IgnoringSelfEvents(base_logger=self._base_logger)) if url_verification_enabled is True: self._middleware_list.append(UrlVerification(base_logger=self._base_logger)) + if attaching_function_token_enabled is True: + self._middleware_list.append(AttachingFunctionToken()) self._init_middleware_list_done = True # ------------------------- @@ -828,6 +836,51 @@ def __call__(*args, **kwargs): return __call__ + def function( + self, + callback_id: Union[str, Pattern], + matchers: Optional[Sequence[Callable[..., bool]]] = None, + middleware: Optional[Sequence[Union[Callable, Middleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: + """Registers a new Function listener. + This method can be used as either a decorator or a method. + # Use this method as a decorator + @app.function("reverse") + def reverse_string(event, client: WebClient, context: BoltContext): + try: + string_to_reverse = event["inputs"]["stringToReverse"] + client.functions_completeSuccess( + function_execution_id=context.function_execution_id, + outputs={"reverseString": string_to_reverse[::-1]}, + ) + except Exception as e: + client.api_call( + client.functions_completeError( + function_execution_id=context.function_execution_id, + error=f"Cannot reverse string (error: {e})", + ) + raise e + # Pass a function to this method + app.function("reverse")(reverse_string) + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + Args: + callback_id: The callback id to identify the function + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.function_executed(callback_id=callback_id, base_logger=self._base_logger) + return self._register_listener(functions, primary_matcher, matchers, middleware, True) + + return __call__ + # ------------------------- # slash commands diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index c70fc2e54..096e811be 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -77,6 +77,7 @@ AsyncRequestVerification, AsyncIgnoringSelfEvents, AsyncUrlVerification, + AsyncAttachingFunctionToken, ) from slack_bolt.middleware.async_custom_middleware import ( AsyncMiddleware, @@ -122,6 +123,7 @@ def __init__( ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + attaching_function_token_enabled: bool = True, # for the OAuth flow oauth_settings: Optional[AsyncOAuthSettings] = None, oauth_flow: Optional[AsyncOAuthFlow] = None, @@ -184,6 +186,8 @@ async def message_hello(message, say): # async function that verify the endpoint for Events API in HTTP Mode requests. ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack. + attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True). + `AsyncAttachingFunctionToken` is a built-in middleware that handles tokens with function requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -354,6 +358,7 @@ async def message_hello(message, say): # async function ignoring_self_events_enabled=ignoring_self_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, + attaching_function_token_enabled=attaching_function_token_enabled, ) self._server: Optional[AsyncSlackAppServer] = None @@ -364,6 +369,7 @@ def _init_async_middleware_list( ignoring_self_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, + attaching_function_token_enabled: bool = True, ): if self._init_middleware_list_done: return @@ -403,6 +409,8 @@ def _init_async_middleware_list( self._async_middleware_list.append(AsyncIgnoringSelfEvents(base_logger=self._base_logger)) if url_verification_enabled is True: self._async_middleware_list.append(AsyncUrlVerification(base_logger=self._base_logger)) + if attaching_function_token_enabled is True: + self._async_middleware_list.append(AsyncAttachingFunctionToken()) self._init_middleware_list_done = True # ------------------------- @@ -861,6 +869,52 @@ def __call__(*args, **kwargs): return __call__ + def function( + self, + callback_id: Union[str, Pattern], + matchers: Optional[Sequence[Callable[..., Awaitable[bool]]]] = None, + middleware: Optional[Sequence[Union[Callable, AsyncMiddleware]]] = None, + ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: + """Registers a new Function listener. + This method can be used as either a decorator or a method. + # Use this method as a decorator + @app.function("reverse") + async def reverse_string(event, client: AsyncWebClient, complete: AsyncComplete): + try: + string_to_reverse = event["inputs"]["stringToReverse"] + await client.functions_completeSuccess( + function_execution_id=context.function_execution_id, + outputs={"reverseString": string_to_reverse[::-1]}, + ) + except Exception as e: + await client.functions_completeError( + function_execution_id=context.function_execution_id, + error=f"Cannot reverse string (error: {e})", + ) + raise e + # Pass a function to this method + app.function("reverse")(reverse_string) + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + Args: + callback_id: The callback id to identify the function + matchers: A list of listener matcher functions. + Only when all the matchers return True, the listener function can be invoked. + middleware: A list of lister middleware functions. + Only when all the middleware call `next()` method, the listener function can be invoked. + """ + + matchers = list(matchers) if matchers else [] + middleware = list(middleware) if middleware else [] + + def __call__(*args, **kwargs): + functions = self._to_listener_functions(kwargs) if kwargs else list(args) + primary_matcher = builtin_matchers.function_executed( + callback_id=callback_id, base_logger=self._base_logger, asyncio=True + ) + return self._register_listener(functions, primary_matcher, matchers, middleware, True) + + return __call__ + # ------------------------- # slash commands diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 81105c6df..a6f366aa5 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -4,6 +4,8 @@ from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.base_context import BaseContext +from slack_bolt.context.complete.async_complete import AsyncComplete +from slack_bolt.context.fail.async_fail import AsyncFail from slack_bolt.context.respond.async_respond import AsyncRespond from slack_bolt.context.say.async_say import AsyncSay from slack_bolt.util.utils import create_copy @@ -122,3 +124,51 @@ async def handle_button_clicks(ack, respond): ssl=self.client.ssl, ) return self["respond"] + + @property + def complete(self) -> AsyncComplete: + """`complete()` function for this request. Once a custom function's state is set to complete, + any outputs the function returns will be passed along to the next step of its housing workflow, + or complete the workflow if the function is the last step in a workflow. Additionally, + any interactivity handlers associated to a function invocation will no longer be invocable. + + @app.function("reverse") + async def handle_button_clicks(ack, complete): + await ack() + await complete(outputs={"stringReverse":"olleh"}) + + @app.function("reverse") + async def handle_button_clicks(context): + await context.ack() + await context.complete(outputs={"stringReverse":"olleh"}) + + Returns: + Callable `complete()` function + """ + if "complete" not in self: + self["complete"] = AsyncComplete(client=self.client, function_execution_id=self.function_execution_id) + return self["complete"] + + @property + def fail(self) -> AsyncFail: + """`fail()` function for this request. Once a custom function's state is set to error, + its housing workflow will be interrupted and any provided error message will be passed + on to the end user through SlackBot. Additionally, any interactivity handlers associated + to a function invocation will no longer be invocable. + + @app.function("reverse") + async def handle_button_clicks(ack, fail): + await ack() + await fail(error="something went wrong") + + @app.function("reverse") + async def handle_button_clicks(context): + await context.ack() + await context.fail(error="something went wrong") + + Returns: + Callable `fail()` function + """ + if "fail" not in self: + self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id) + return self["fail"] diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index bcbdae3c2..4c53d0e8c 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -2,7 +2,7 @@ # Note: Since 2021.12.8, the pytype code analyzer does not properly work for this file from logging import Logger -from typing import Optional, Tuple +from typing import Any, Dict, Optional, Tuple from slack_bolt.authorization import AuthorizeResult @@ -24,14 +24,19 @@ class BaseContext(dict): "response_url", "matches", "authorize_result", + "function_bot_access_token", "bot_token", "bot_id", "bot_user_id", "user_token", + "function_execution_id", + "inputs", "client", "ack", "say", "respond", + "complete", + "fail", ] @property @@ -103,6 +108,16 @@ def matches(self) -> Optional[Tuple]: """Returns all the matched parts in message listener's regexp""" return self.get("matches") + @property + def function_execution_id(self) -> Optional[str]: + """The `function_execution_id` associated with this request. Only available for function related events""" + return self.get("function_execution_id") + + @property + def inputs(self) -> Optional[Dict[str, Any]]: + """The `inputs` associated with this request. Only available for function related events""" + return self.get("inputs") + # -------------------------------- @property @@ -110,6 +125,11 @@ def authorize_result(self) -> Optional[AuthorizeResult]: """The authorize result resolved for this request.""" return self.get("authorize_result") + @property + def function_bot_access_token(self) -> Optional[str]: + """The bot token resolved for this function request. Only available for function related events""" + return self.get("function_bot_access_token") + @property def bot_token(self) -> Optional[str]: """The bot token resolved for this request.""" diff --git a/slack_bolt/context/complete/__init__.py b/slack_bolt/context/complete/__init__.py new file mode 100644 index 000000000..823375ca2 --- /dev/null +++ b/slack_bolt/context/complete/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .complete import Complete + +__all__ = [ + "Complete", +] diff --git a/slack_bolt/context/complete/async_complete.py b/slack_bolt/context/complete/async_complete.py new file mode 100644 index 000000000..9fd9a9b28 --- /dev/null +++ b/slack_bolt/context/complete/async_complete.py @@ -0,0 +1,34 @@ +from typing import Any, Dict, Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncComplete: + client: AsyncWebClient + function_execution_id: Optional[str] + + def __init__( + self, + client: AsyncWebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + + async def __call__(self, outputs: Dict[str, Any] = {}) -> AsyncSlackResponse: + """Signal the successful completion of the custom function. + + Kwargs: + outputs: Json serializable object containing the output values + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("complete is unsupported here as there is no function_execution_id") + + return await self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs) diff --git a/slack_bolt/context/complete/complete.py b/slack_bolt/context/complete/complete.py new file mode 100644 index 000000000..118698390 --- /dev/null +++ b/slack_bolt/context/complete/complete.py @@ -0,0 +1,34 @@ +from typing import Any, Dict, Optional + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class Complete: + client: WebClient + function_execution_id: Optional[str] + + def __init__( + self, + client: WebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + + def __call__(self, outputs: Dict[str, Any] = {}) -> SlackResponse: + """Signal the successful completion of the custom function. + + Kwargs: + outputs: Json serializable object containing the output values + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("complete is unsupported here as there is no function_execution_id") + + return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs) diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index 570a7080e..8cca99b90 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -5,6 +5,8 @@ from slack_bolt.context.ack import Ack from slack_bolt.context.base_context import BaseContext +from slack_bolt.context.complete import Complete +from slack_bolt.context.fail import Fail from slack_bolt.context.respond import Respond from slack_bolt.context.say import Say from slack_bolt.util.utils import create_copy @@ -124,3 +126,51 @@ def handle_button_clicks(ack, respond): ssl=self.client.ssl, ) return self["respond"] + + @property + def complete(self) -> Complete: + """`complete()` function for this request. Once a custom function's state is set to complete, + any outputs the function returns will be passed along to the next step of its housing workflow, + or complete the workflow if the function is the last step in a workflow. Additionally, + any interactivity handlers associated to a function invocation will no longer be invocable. + + @app.function("reverse") + def handle_button_clicks(ack, complete): + ack() + complete(outputs={"stringReverse":"olleh"}) + + @app.function("reverse") + def handle_button_clicks(context): + context.ack() + context.complete(outputs={"stringReverse":"olleh"}) + + Returns: + Callable `complete()` function + """ + if "complete" not in self: + self["complete"] = Complete(client=self.client, function_execution_id=self.function_execution_id) + return self["complete"] + + @property + def fail(self) -> Fail: + """`fail()` function for this request. Once a custom function's state is set to error, + its housing workflow will be interrupted and any provided error message will be passed + on to the end user through SlackBot. Additionally, any interactivity handlers associated + to a function invocation will no longer be invocable. + + @app.function("reverse") + def handle_button_clicks(ack, fail): + ack() + fail(error="something went wrong") + + @app.function("reverse") + def handle_button_clicks(context): + context.ack() + context.fail(error="something went wrong") + + Returns: + Callable `fail()` function + """ + if "fail" not in self: + self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id) + return self["fail"] diff --git a/slack_bolt/context/fail/__init__.py b/slack_bolt/context/fail/__init__.py new file mode 100644 index 000000000..b306f8452 --- /dev/null +++ b/slack_bolt/context/fail/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .fail import Fail + +__all__ = [ + "Fail", +] diff --git a/slack_bolt/context/fail/async_fail.py b/slack_bolt/context/fail/async_fail.py new file mode 100644 index 000000000..af7d1a605 --- /dev/null +++ b/slack_bolt/context/fail/async_fail.py @@ -0,0 +1,34 @@ +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncFail: + client: AsyncWebClient + function_execution_id: Optional[str] + + def __init__( + self, + client: AsyncWebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + + async def __call__(self, error: str = "") -> AsyncSlackResponse: + """Signal that the custom function failed to complete. + + Kwargs: + error: Error message to return to slack + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("fail is unsupported here as there is no function_execution_id") + + return await self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) diff --git a/slack_bolt/context/fail/fail.py b/slack_bolt/context/fail/fail.py new file mode 100644 index 000000000..1bb6389fe --- /dev/null +++ b/slack_bolt/context/fail/fail.py @@ -0,0 +1,34 @@ +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class Fail: + client: WebClient + function_execution_id: Optional[str] + + def __init__( + self, + client: WebClient, + function_execution_id: Optional[str], + ): + self.client = client + self.function_execution_id = function_execution_id + + def __call__(self, error: str = "") -> SlackResponse: + """Signal that the custom function failed to complete. + + Kwargs: + error: Error message to return to slack + + Returns: + SlackResponse: The response object returned from slack + + Raises: + ValueError: If this function cannot be used. + """ + if self.function_execution_id is None: + raise ValueError("fail is unsupported here as there is no function_execution_id") + + return self.client.functions_completeError(function_execution_id=self.function_execution_id, error=error) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 99e878463..96ef6214e 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -5,6 +5,8 @@ from slack_bolt.context import BoltContext from slack_bolt.context.ack import Ack +from slack_bolt.context.complete import Complete +from slack_bolt.context.fail import Fail from slack_bolt.context.respond import Respond from slack_bolt.context.say import Say from slack_bolt.request import BoltRequest @@ -82,6 +84,10 @@ def handle_buttons(args): """`say()` utility function, which calls `chat.postMessage` API with the associated channel ID""" respond: Respond """`respond()` utility function, which utilizes the associated `response_url`""" + complete: Complete + """`complete()` utility function, signals a successful completion of the custom function""" + fail: Fail + """`fail()` utility function, signal that the custom function failed to complete""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -108,6 +114,8 @@ def __init__( ack: Ack, say: Say, respond: Respond, + complete: Complete, + fail: Fail, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -133,5 +141,7 @@ def __init__( self.ack: Ack = ack self.say: Say = say self.respond: Respond = respond + self.complete: Complete = complete + self.fail: Fail = fail self.next: Callable[[], None] = next self.next_: Callable[[], None] = next diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index db3f8663d..7c65acf6f 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -4,6 +4,8 @@ from slack_bolt.context.ack.async_ack import AsyncAck from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.complete.async_complete import AsyncComplete +from slack_bolt.context.fail.async_fail import AsyncFail from slack_bolt.context.respond.async_respond import AsyncRespond from slack_bolt.context.say.async_say import AsyncSay from slack_bolt.request.async_request import AsyncBoltRequest @@ -81,6 +83,10 @@ async def handle_buttons(args): """`say()` utility function, which calls chat.postMessage API with the associated channel ID""" respond: AsyncRespond """`respond()` utility function, which utilizes the associated `response_url`""" + complete: AsyncComplete + """`complete()` utility function, signals a successful completion of the custom function""" + fail: AsyncFail + """`fail()` utility function, signal that the custom function failed to complete""" # middleware next: Callable[[], Awaitable[None]] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -107,6 +113,8 @@ def __init__( ack: AsyncAck, say: AsyncSay, respond: AsyncRespond, + complete: AsyncComplete, + fail: AsyncFail, next: Callable[[], Awaitable[None]], **kwargs # noqa ): @@ -129,5 +137,7 @@ def __init__( self.ack: AsyncAck = ack self.say: AsyncSay = say self.respond: AsyncRespond = respond + self.complete: AsyncComplete = complete + self.fail: AsyncFail = fail self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index bfee11f87..b6b0d96b2 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -52,6 +52,8 @@ def build_async_required_kwargs( "ack": request.context.ack, "say": request.context.say, "respond": request.context.respond, + "complete": request.context.complete, + "fail": request.context.fail, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 99a974c03..945e43d3d 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -52,6 +52,8 @@ def build_required_kwargs( "ack": request.context.ack, "say": request.context.say, "respond": request.context.respond, + "complete": request.context.complete, + "fail": request.context.fail, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index c6547f919..507b0c7ad 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -6,6 +6,7 @@ from slack_bolt.error import BoltError from slack_bolt.request.payload_utils import ( is_block_actions, + is_function, is_global_shortcut, is_message_shortcut, is_attachment_action, @@ -176,6 +177,17 @@ def _verify_message_event_type(event_type: str) -> None: raise ValueError(error_message_event_type(event_type)) +def function_executed( + callback_id: Union[str, Pattern], + asyncio: bool = False, + base_logger: Optional[Logger] = None, +) -> Union[ListenerMatcher, "AsyncListenerMatcher"]: + def func(body: Dict[str, Any]) -> bool: + return is_function(body) and _matches(callback_id, body.get("event", {}).get("function", {}).get("callback_id", "")) + + return build_listener_matcher(func, asyncio, base_logger) + + def workflow_step_execute( callback_id: Union[str, Pattern], asyncio: bool = False, diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index 57c6bc235..e41c6811b 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -7,6 +7,7 @@ from slack_bolt.request.payload_utils import ( is_action, is_event, + is_function, is_options, is_shortcut, is_slash_command, @@ -266,7 +267,27 @@ def warning_unhandled_request( # type: ignore ) if is_event(req.body): # @app.event - event_type = req.body.get("event", {}).get("type") + event = req.body.get("event", {}) + event_type = event.get("type") + if is_function(req.body): + # @app.function + callback_id = event.get("function", {}).get("callback_id", "function_id") + return _build_unhandled_request_suggestion( + default_message, + f""" +@app.function("{callback_id}") +{'async ' if is_async else ''}def handle_some_function(ack, body, complete, fail, logger): + {'await ' if is_async else ''}ack() + logger.info(body) + try: + # TODO: do something here + outputs = {{}} + {'await ' if is_async else ''}complete(outputs=outputs) + except Exception as e: + error = f"Failed to handle a function request (error: {{e}})" + {'await ' if is_async else ''}fail(error=error) +""", + ) return _build_unhandled_request_suggestion( default_message, f""" diff --git a/slack_bolt/middleware/__init__.py b/slack_bolt/middleware/__init__.py index ef42a6a31..256bf5c45 100644 --- a/slack_bolt/middleware/__init__.py +++ b/slack_bolt/middleware/__init__.py @@ -16,6 +16,7 @@ from .request_verification import RequestVerification from .ssl_check import SslCheck from .url_verification import UrlVerification +from .attaching_function_token import AttachingFunctionToken builtin_middleware_classes = [ SslCheck, @@ -24,6 +25,7 @@ MultiTeamsAuthorization, IgnoringSelfEvents, UrlVerification, + AttachingFunctionToken, ] for cls in builtin_middleware_classes: Middleware.register(cls) @@ -37,5 +39,6 @@ "RequestVerification", "SslCheck", "UrlVerification", + "AttachingFunctionToken", "builtin_middleware_classes", ] diff --git a/slack_bolt/middleware/async_builtins.py b/slack_bolt/middleware/async_builtins.py index 2b279cc27..d2d82c1fb 100644 --- a/slack_bolt/middleware/async_builtins.py +++ b/slack_bolt/middleware/async_builtins.py @@ -9,6 +9,7 @@ from .message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, ) +from .attaching_function_token.async_attaching_function_token import AsyncAttachingFunctionToken __all__ = [ "AsyncIgnoringSelfEvents", @@ -16,4 +17,5 @@ "AsyncSslCheck", "AsyncUrlVerification", "AsyncMessageListenerMatches", + "AsyncAttachingFunctionToken", ] diff --git a/slack_bolt/middleware/attaching_function_token/__init__.py b/slack_bolt/middleware/attaching_function_token/__init__.py new file mode 100644 index 000000000..5531cc897 --- /dev/null +++ b/slack_bolt/middleware/attaching_function_token/__init__.py @@ -0,0 +1,5 @@ +from .attaching_function_token import AttachingFunctionToken + +__all__ = [ + "AttachingFunctionToken", +] diff --git a/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.py b/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.py new file mode 100644 index 000000000..dbc1181c8 --- /dev/null +++ b/slack_bolt/middleware/attaching_function_token/async_attaching_function_token.py @@ -0,0 +1,20 @@ +from typing import Callable, Awaitable + +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.middleware.async_middleware import AsyncMiddleware + + +class AsyncAttachingFunctionToken(AsyncMiddleware): # type: ignore + async def async_process( + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + # This method is not supposed to be invoked by bolt-python users + next: Callable[[], Awaitable[BoltResponse]], + ) -> BoltResponse: + if req.context.function_bot_access_token is not None: + req.context.client.token = req.context.function_bot_access_token + + return await next() diff --git a/slack_bolt/middleware/attaching_function_token/attaching_function_token.py b/slack_bolt/middleware/attaching_function_token/attaching_function_token.py new file mode 100644 index 000000000..69b030ec9 --- /dev/null +++ b/slack_bolt/middleware/attaching_function_token/attaching_function_token.py @@ -0,0 +1,20 @@ +from typing import Callable + +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.middleware.middleware import Middleware + + +class AttachingFunctionToken(Middleware): # type: ignore + def process( + self, + *, + req: BoltRequest, + resp: BoltResponse, + # This method is not supposed to be invoked by bolt-python users + next: Callable[[], BoltResponse], + ) -> BoltResponse: + if req.context.function_bot_access_token is not None: + req.context.client.token = req.context.function_bot_access_token + + return next() diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index 1d662c1d5..f1f00dece 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -3,6 +3,9 @@ from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.request.internals import ( extract_enterprise_id, + extract_function_bot_access_token, + extract_function_execution_id, + extract_function_inputs, extract_is_enterprise_install, extract_team_id, extract_user_id, @@ -41,6 +44,15 @@ def build_async_context( channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id + function_execution_id = extract_function_execution_id(body) + if function_execution_id: + context["function_execution_id"] = function_execution_id + function_bot_access_token = extract_function_bot_access_token(body) + if function_bot_access_token is not None: + context["function_bot_access_token"] = function_bot_access_token + function_inputs = extract_function_inputs(body) + if function_inputs is not None: + context["inputs"] = function_inputs if "response_url" in body: context["response_url"] = body["response_url"] elif "response_urls" in body: diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index 1a3d3e428..c19061d82 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -208,6 +208,32 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: return None +def extract_function_execution_id(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("function_execution_id") is not None: + return payload.get("function_execution_id") + if payload.get("event") is not None: + return extract_function_execution_id(payload["event"]) + if payload.get("function_data") is not None: + return payload["function_data"].get("execution_id") + return None + + +def extract_function_bot_access_token(payload: Dict[str, Any]) -> Optional[str]: + if payload.get("bot_access_token") is not None: + return payload.get("bot_access_token") + if payload.get("event") is not None: + return payload["event"].get("bot_access_token") + return None + + +def extract_function_inputs(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if payload.get("event") is not None: + return payload["event"].get("inputs") + if payload.get("function_data") is not None: + return payload["function_data"].get("inputs") + return None + + def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: context["is_enterprise_install"] = extract_is_enterprise_install(body) enterprise_id = extract_enterprise_id(body) @@ -232,6 +258,15 @@ def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id + function_execution_id = extract_function_execution_id(body) + if function_execution_id is not None: + context["function_execution_id"] = function_execution_id + function_bot_access_token = extract_function_bot_access_token(body) + if function_bot_access_token is not None: + context["function_bot_access_token"] = function_bot_access_token + inputs = extract_function_inputs(body) + if inputs is not None: + context["inputs"] = inputs if "response_url" in body: context["response_url"] = body["response_url"] elif "response_urls" in body: diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 89ecc5822..dfd2163c2 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -20,6 +20,10 @@ def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None +def is_function(body: Dict[str, Any]) -> bool: + return is_event(body) and "function_executed" == body["event"]["type"] and "function_execution_id" in body["event"] + + def is_event(body: Dict[str, Any]) -> bool: return body is not None and _is_expected_type(body, "event_callback") and "event" in body and "type" in body["event"] diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py index 24d310994..9e1e0769b 100644 --- a/tests/mock_web_api_server.py +++ b/tests/mock_web_api_server.py @@ -3,6 +3,7 @@ import logging import threading import time +import re from http import HTTPStatus from http.server import HTTPServer, SimpleHTTPRequestHandler from typing import Type, Optional @@ -22,6 +23,9 @@ def is_valid_token(self): def is_valid_user_token(self): return "Authorization" in self.headers and str(self.headers["Authorization"]).startswith("Bearer xoxp-") + def is_valid_function_bot_access_token(self): + return "Authorization" in self.headers and str(self.headers["Authorization"]).startswith("Bearer xwfp-") + def set_common_headers(self): self.send_header("content-type", "application/json;charset=utf-8") self.send_header("connection", "close") @@ -172,7 +176,7 @@ def _handle(self): self.wfile.write(self.user_auth_test_response.encode("utf-8")) return - if self.is_valid_token(): + if self.is_valid_token() or self.is_valid_function_bot_access_token(): if path == "/auth.test": self.send_response(200) self.set_common_headers() @@ -186,7 +190,7 @@ def _handle(self): self.logger.info(f"request: {path} {request_body}") header = self.headers["authorization"] - pattern = str(header).split("xoxb-", 1)[1] + pattern = re.split(r"xoxb-|xwfp-", header, 1)[1] if pattern.isnumeric(): self.send_response(int(pattern)) self.set_common_headers() diff --git a/tests/scenario_tests/test_app_decorators.py b/tests/scenario_tests/test_app_decorators.py index 2cf299eb8..645725add 100644 --- a/tests/scenario_tests/test_app_decorators.py +++ b/tests/scenario_tests/test_app_decorators.py @@ -43,6 +43,13 @@ def handle_message_events(body: dict): handle_message_events({}) assert isinstance(handle_message_events, Callable) + @app.function("reverse") + def handle_function_events(body: dict): + assert body is not None + + handle_function_events({}) + assert isinstance(handle_function_events, Callable) + @app.command("/hello") def handle_commands(ack: Ack, body: dict): assert body is not None diff --git a/tests/scenario_tests/test_function.py b/tests/scenario_tests/test_function.py new file mode 100644 index 000000000..c6b25cf08 --- /dev/null +++ b/tests/scenario_tests/test_function.py @@ -0,0 +1,231 @@ +import json +import time +import pytest + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestFunction: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request_from_body(self, message_body: dict) -> BoltRequest: + timestamp, body = str(int(time.time())), json.dumps(message_body) + return BoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + def test_valid_callback_id_success(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(function_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert self.mock_received_requests["/functions.completeSuccess"] == 1 + + def test_valid_callback_id_complete(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(complete_it) + + request = self.build_request_from_body(function_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert self.mock_received_requests["/functions.completeSuccess"] == 1 + + def test_valid_callback_id_error(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse_error) + + request = self.build_request_from_body(function_body) + response = app.dispatch(request) + assert response.status == 200 + assert_auth_test_count(self, 1) + assert self.mock_received_requests["/functions.completeError"] == 1 + + def test_invalid_callback_id(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(wrong_id_function_body) + response = app.dispatch(request) + assert response.status == 404 + assert_auth_test_count(self, 1) + + def test_invalid_declaration(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + func = app.function("reverse") + + with pytest.raises(TypeError): + func("hello world") + + +function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + +wrong_id_function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "wrong_callback_id", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + + +def reverse(body, event, context, client, complete, inputs): + assert body == function_body + assert event == function_body["event"] + assert inputs == function_body["event"]["inputs"] + assert context.function_execution_id == "Fx111" + assert complete.function_execution_id == "Fx111" + assert context.function_bot_access_token == "xwfp-valid" + assert context.client.token == "xwfp-valid" + assert client.token == "xwfp-valid" + assert complete.client.token == "xwfp-valid" + complete( + outputs={"reverseString": "olleh"}, + ) + + +def reverse_error(body, event, fail): + assert body == function_body + assert event == function_body["event"] + assert fail.function_execution_id == "Fx111" + fail(error="there was an error") + + +def complete_it(body, event, complete): + assert body == function_body + assert event == function_body["event"] + complete(outputs={}) diff --git a/tests/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py new file mode 100644 index 000000000..56ff7c0b4 --- /dev/null +++ b/tests/scenario_tests_async/test_function.py @@ -0,0 +1,239 @@ +import asyncio +import json +import time + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, + assert_auth_test_count_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncFunction: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + return { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + + def build_request_from_body(self, message_body: dict) -> AsyncBoltRequest: + timestamp, body = str(int(time.time())), json.dumps(message_body) + return AsyncBoltRequest(body=body, headers=self.build_headers(timestamp, body)) + + @pytest.mark.asyncio + async def test_mock_server_is_running(self): + resp = await self.web_client.api_test() + assert resp is not None + + @pytest.mark.asyncio + async def test_valid_callback_id_success(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(function_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + assert self.mock_received_requests["/functions.completeSuccess"] == 1 + + @pytest.mark.asyncio + async def test_valid_callback_id_complete(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(complete_it) + + request = self.build_request_from_body(function_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + assert self.mock_received_requests["/functions.completeSuccess"] == 1 + + @pytest.mark.asyncio + async def test_valid_callback_id_error(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse_error) + + request = self.build_request_from_body(function_body) + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_auth_test_count_async(self, 1) + assert self.mock_received_requests["/functions.completeError"] == 1 + + @pytest.mark.asyncio + async def test_invalid_callback_id(self): + app = AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + ) + app.function("reverse")(reverse) + + request = self.build_request_from_body(wrong_id_function_body) + response = await app.async_dispatch(request) + assert response.status == 404 + await assert_auth_test_count_async(self, 1) + + +function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + +wrong_id_function_body = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "wrong_callback_id", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + "bot_access_token": "xwfp-valid", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + + +async def reverse(body, event, client, context, complete, inputs): + assert body == function_body + assert event == function_body["event"] + assert inputs == function_body["event"]["inputs"] + assert context.function_execution_id == "Fx111" + assert complete.function_execution_id == "Fx111" + assert context.function_bot_access_token == "xwfp-valid" + assert context.client.token == "xwfp-valid" + assert client.token == "xwfp-valid" + assert complete.client.token == "xwfp-valid" + await complete( + outputs={"reverseString": "olleh"}, + ) + + +async def reverse_error(body, event, fail): + assert body == function_body + assert event == function_body["event"] + assert fail.function_execution_id == "Fx111" + await fail( + error="there was an error", + ) + + +async def complete_it(body, event, complete): + assert body == function_body + assert event == function_body["event"] + await complete( + outputs={}, + ) diff --git a/tests/slack_bolt/context/test_complete.py b/tests/slack_bolt/context/test_complete.py new file mode 100644 index 000000000..a920c41eb --- /dev/null +++ b/tests/slack_bolt/context/test_complete.py @@ -0,0 +1,32 @@ +import pytest + +from slack_sdk import WebClient +from slack_bolt.context.complete import Complete +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestComplete: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_complete(self): + complete_success = Complete(client=self.web_client, function_execution_id="fn1111") + + response = complete_success(outputs={"key": "value"}) + + assert response.status_code == 200 + + def test_complete_no_function_execution_id(self): + complete = Complete(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + complete(outputs={"key": "value"}) diff --git a/tests/slack_bolt/context/test_fail.py b/tests/slack_bolt/context/test_fail.py new file mode 100644 index 000000000..e4704d376 --- /dev/null +++ b/tests/slack_bolt/context/test_fail.py @@ -0,0 +1,32 @@ +import pytest + +from slack_sdk import WebClient +from slack_bolt.context.fail import Fail +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestFail: + def setup_method(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = WebClient(token=valid_token, base_url=mock_api_server_base_url) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + + def test_fail(self): + fail = Fail(client=self.web_client, function_execution_id="fn1111") + + response = fail(error="something went wrong") + + assert response.status_code == 200 + + def test_fail_no_function_execution_id(self): + fail = Fail(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + fail(error="there was an error") diff --git a/tests/slack_bolt/kwargs_injection/test_args.py b/tests/slack_bolt/kwargs_injection/test_args.py index dbee91bbe..530a5a703 100644 --- a/tests/slack_bolt/kwargs_injection/test_args.py +++ b/tests/slack_bolt/kwargs_injection/test_args.py @@ -26,6 +26,8 @@ def test_build(self): "ack", "say", "respond", + "complete", + "fail", "next", ] arg_params: dict = build_required_kwargs( diff --git a/tests/slack_bolt/logger/test_unmatched_suggestions.py b/tests/slack_bolt/logger/test_unmatched_suggestions.py index 4ee92b84c..2c0c82b99 100644 --- a/tests/slack_bolt/logger/test_unmatched_suggestions.py +++ b/tests/slack_bolt/logger/test_unmatched_suggestions.py @@ -81,6 +81,33 @@ def handle_app_mention_events(body, logger): == message ) + def test_function_event(self): + req: BoltRequest = BoltRequest(body=function_event, mode="socket_mode") + filtered_body = { + "type": "event_callback", + "event": {"type": "function_executed"}, + } + message = warning_unhandled_request(req) + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.function("reverse") +def handle_some_function(ack, body, complete, fail, logger): + ack() + logger.info(body) + try: + # TODO: do something here + outputs = {{}} + complete(outputs=outputs) + except Exception as e: + error = f"Failed to handle a function request (error: {{e}})" + fail(error=error) +""" + == message + ) + def test_commands(self): req: BoltRequest = BoltRequest(body=slash_command, mode="socket_mode") message = warning_unhandled_request(req) @@ -399,6 +426,50 @@ def test_step(self): "event_time": 1595926230, } +function_event = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + slash_command = { "token": "fixed-verification-token", "team_id": "T111", diff --git a/tests/slack_bolt/request/test_internals.py b/tests/slack_bolt/request/test_internals.py index 6975a307d..752fa6d2d 100644 --- a/tests/slack_bolt/request/test_internals.py +++ b/tests/slack_bolt/request/test_internals.py @@ -2,6 +2,8 @@ from slack_bolt.request.internals import ( extract_channel_id, + extract_function_bot_access_token, + extract_function_inputs, extract_user_id, extract_team_id, extract_enterprise_id, @@ -10,6 +12,7 @@ extract_actor_enterprise_id, extract_actor_team_id, extract_actor_user_id, + extract_function_execution_id, ) @@ -82,6 +85,32 @@ def teardown_method(self): }, ] + function_event_requests = [ + { + "type": "event_callback", + "token": "xxx", + "enterprise_id": "E111", + "team_id": "T111", + "event": {"function_execution_id": "Fx111", "bot_access_token": "xwfp-xxx", "inputs": {"customer_id": "Ux111"}}, + }, + { + "type": "block_actions", + "enterprise_id": "E111", + "team_id": "T111", + "bot_access_token": "xwfp-xxx", + "function_data": {"execution_id": "Fx111", "inputs": {"customer_id": "Ux111"}}, + "interactivity": {"interactivity_pointer": "111.222.xxx"}, + }, + { + "type": "view_submission", + "enterprise_id": "E111", + "team_id": "T111", + "bot_access_token": "xwfp-xxx", + "function_data": {"execution_id": "Fx111", "inputs": {"customer_id": "Ux111"}}, + "interactivity": {"interactivity_pointer": "111.222.xxx"}, + }, + ] + slack_connect_authorizations = [ { "enterprise_id": "INSTALLED_ENTERPRISE_ID", @@ -275,6 +304,9 @@ def test_team_id_extraction(self): for req in self.no_enterprise_no_channel_requests: team_id = extract_team_id(req) assert team_id == "T111" + for req in self.function_event_requests: + team_id = extract_team_id(req) + assert team_id == "T111" def test_enterprise_id_extraction(self): for req in self.requests: @@ -286,6 +318,24 @@ def test_enterprise_id_extraction(self): for req in self.no_enterprise_no_channel_requests: enterprise_id = extract_enterprise_id(req) assert enterprise_id is None + for req in self.function_event_requests: + enterprise_id = extract_enterprise_id(req) + assert enterprise_id == "E111" + + def test_bot_access_token_extraction(self): + for req in self.function_event_requests: + function_bot_access_token = extract_function_bot_access_token(req) + assert function_bot_access_token == "xwfp-xxx" + + def test_function_execution_id_extraction(self): + for req in self.function_event_requests: + function_execution_id = extract_function_execution_id(req) + assert function_execution_id == "Fx111" + + def test_function_inputs_extraction(self): + for req in self.function_event_requests: + inputs = extract_function_inputs(req) + assert inputs == {"customer_id": "Ux111"} def test_is_enterprise_install_extraction(self): for req in self.requests: diff --git a/tests/slack_bolt_async/context/test_async_complete.py b/tests/slack_bolt_async/context/test_async_complete.py new file mode 100644 index 000000000..f2fd115ec --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_complete.py @@ -0,0 +1,38 @@ +import pytest +import asyncio + +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.complete.async_complete import AsyncComplete +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestAsyncComplete: + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + + @pytest.mark.asyncio + async def test_complete(self): + complete_success = AsyncComplete(client=self.web_client, function_execution_id="fn1111") + + response = await complete_success(outputs={"key": "value"}) + + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_complete_no_function_execution_id(self): + complete = AsyncComplete(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + await complete(outputs={"key": "value"}) diff --git a/tests/slack_bolt_async/context/test_async_fail.py b/tests/slack_bolt_async/context/test_async_fail.py new file mode 100644 index 000000000..854bc7521 --- /dev/null +++ b/tests/slack_bolt_async/context/test_async_fail.py @@ -0,0 +1,38 @@ +import pytest +import asyncio + +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.fail.async_fail import AsyncFail +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) + + +class TestAsyncFail: + @pytest.fixture + def event_loop(self): + setup_mock_web_api_server(self) + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + self.web_client = AsyncWebClient(token=valid_token, base_url=mock_api_server_base_url) + + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + + @pytest.mark.asyncio + async def test_fail(self): + fail = AsyncFail(client=self.web_client, function_execution_id="fn1111") + + response = await fail(error="something went wrong") + + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_fail_no_function_execution_id(self): + fail = AsyncFail(client=self.web_client, function_execution_id=None) + + with pytest.raises(ValueError): + await fail(error="there was an error") diff --git a/tests/slack_bolt_async/kwargs_injection/test_async_args.py b/tests/slack_bolt_async/kwargs_injection/test_async_args.py index e21e9de6f..f0322f620 100644 --- a/tests/slack_bolt_async/kwargs_injection/test_async_args.py +++ b/tests/slack_bolt_async/kwargs_injection/test_async_args.py @@ -28,6 +28,8 @@ def test_build(self): "ack", "say", "respond", + "complete", + "fail", "next", ] arg_params: dict = build_async_required_kwargs( diff --git a/tests/slack_bolt_async/logger/test_unmatched_suggestions.py b/tests/slack_bolt_async/logger/test_unmatched_suggestions.py index b3410baf8..93343c4a2 100644 --- a/tests/slack_bolt_async/logger/test_unmatched_suggestions.py +++ b/tests/slack_bolt_async/logger/test_unmatched_suggestions.py @@ -81,6 +81,33 @@ async def handle_app_mention_events(body, logger): == message ) + def test_function_event(self): + req: AsyncBoltRequest = AsyncBoltRequest(body=function_event, mode="socket_mode") + filtered_body = { + "type": "event_callback", + "event": {"type": "function_executed"}, + } + message = warning_unhandled_request(req) + assert ( + f"""Unhandled request ({filtered_body}) +--- +[Suggestion] You can handle this type of event with the following listener function: + +@app.function("reverse") +async def handle_some_function(ack, body, complete, fail, logger): + await ack() + logger.info(body) + try: + # TODO: do something here + outputs = {{}} + await complete(outputs=outputs) + except Exception as e: + error = f"Failed to handle a function request (error: {{e}})" + await fail(error=error) +""" + == message + ) + def test_commands(self): req: AsyncBoltRequest = AsyncBoltRequest(body=slash_command, mode="socket_mode") message = warning_unhandled_request(req) @@ -399,6 +426,50 @@ def test_step(self): "event_time": 1595926230, } +function_event = { + "token": "verification_token", + "enterprise_id": "E111", + "team_id": "T111", + "api_app_id": "A111", + "event": { + "type": "function_executed", + "function": { + "id": "Fn111", + "callback_id": "reverse", + "title": "Reverse", + "description": "Takes a string and reverses it", + "type": "app", + "input_parameters": [ + { + "type": "string", + "name": "stringToReverse", + "description": "The string to reverse", + "title": "String To Reverse", + "is_required": True, + } + ], + "output_parameters": [ + { + "type": "string", + "name": "reverseString", + "description": "The string in reverse", + "title": "Reverse String", + "is_required": True, + } + ], + "app_id": "A111", + "date_updated": 1659054991, + }, + "inputs": {"stringToReverse": "hello"}, + "function_execution_id": "Fx111", + "event_ts": "1659055013.509853", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1659055013, + "authed_users": ["W111"], +} + slash_command = { "token": "fixed-verification-token", "team_id": "T111", From 1f867bccbb0b2b7db4e8b5cb00a0db774123f5cf Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 25 Jan 2024 12:07:47 -0500 Subject: [PATCH 02/16] versions 1.19.0rc1 --- slack_bolt/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 2dc804083..fc65357c8 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,2 +1,2 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.18.1" +__version__ = "1.19.0rc1" From 5f20da2ae5a6c667d84c40a2b60f095b861ab58c Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 26 Jan 2024 10:29:24 -0500 Subject: [PATCH 03/16] Update slack_bolt/app/app.py Co-authored-by: Kazuhiro Sera --- slack_bolt/app/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index ebada44b2..e448c07a4 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -846,7 +846,7 @@ def function( This method can be used as either a decorator or a method. # Use this method as a decorator @app.function("reverse") - def reverse_string(event, client: WebClient, context: BoltContext): + def reverse_string(event: dict, client: WebClient, context: BoltContext): try: string_to_reverse = event["inputs"]["stringToReverse"] client.functions_completeSuccess( From 1c4325d109f0fd72de01148c000d3a3a9eaed8ef Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 26 Jan 2024 10:29:49 -0500 Subject: [PATCH 04/16] Update slack_bolt/app/async_app.py Co-authored-by: Kazuhiro Sera --- slack_bolt/app/async_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 096e811be..a452f0d1e 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -879,7 +879,7 @@ def function( This method can be used as either a decorator or a method. # Use this method as a decorator @app.function("reverse") - async def reverse_string(event, client: AsyncWebClient, complete: AsyncComplete): + async def reverse_string(event: dict, client: AsyncWebClient, complete: AsyncComplete): try: string_to_reverse = event["inputs"]["stringToReverse"] await client.functions_completeSuccess( From d742e420fe3fea377795d53b22fa5debddc6508b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 26 Jan 2024 10:30:38 -0500 Subject: [PATCH 05/16] Update slack_bolt/app/app.py Co-authored-by: Kazuhiro Sera --- slack_bolt/app/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e448c07a4..1ad84d61e 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -854,7 +854,6 @@ def reverse_string(event: dict, client: WebClient, context: BoltContext): outputs={"reverseString": string_to_reverse[::-1]}, ) except Exception as e: - client.api_call( client.functions_completeError( function_execution_id=context.function_execution_id, error=f"Cannot reverse string (error: {e})", From 87bb93222864b460e3432cec7346985ca2756de8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 26 Jan 2024 10:47:25 -0500 Subject: [PATCH 06/16] Improve default values for helper functions --- slack_bolt/context/complete/async_complete.py | 6 ++++-- slack_bolt/context/complete/complete.py | 4 ++-- slack_bolt/context/fail/async_fail.py | 2 +- slack_bolt/context/fail/fail.py | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/slack_bolt/context/complete/async_complete.py b/slack_bolt/context/complete/async_complete.py index 9fd9a9b28..fe3d796d1 100644 --- a/slack_bolt/context/complete/async_complete.py +++ b/slack_bolt/context/complete/async_complete.py @@ -16,7 +16,7 @@ def __init__( self.client = client self.function_execution_id = function_execution_id - async def __call__(self, outputs: Dict[str, Any] = {}) -> AsyncSlackResponse: + async def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> AsyncSlackResponse: """Signal the successful completion of the custom function. Kwargs: @@ -31,4 +31,6 @@ async def __call__(self, outputs: Dict[str, Any] = {}) -> AsyncSlackResponse: if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") - return await self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs) + return await self.client.functions_completeSuccess( + function_execution_id=self.function_execution_id, outputs=outputs or {} + ) diff --git a/slack_bolt/context/complete/complete.py b/slack_bolt/context/complete/complete.py index 118698390..acba3a412 100644 --- a/slack_bolt/context/complete/complete.py +++ b/slack_bolt/context/complete/complete.py @@ -16,7 +16,7 @@ def __init__( self.client = client self.function_execution_id = function_execution_id - def __call__(self, outputs: Dict[str, Any] = {}) -> SlackResponse: + def __call__(self, outputs: Optional[Dict[str, Any]] = None) -> SlackResponse: """Signal the successful completion of the custom function. Kwargs: @@ -31,4 +31,4 @@ def __call__(self, outputs: Dict[str, Any] = {}) -> SlackResponse: if self.function_execution_id is None: raise ValueError("complete is unsupported here as there is no function_execution_id") - return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs) + return self.client.functions_completeSuccess(function_execution_id=self.function_execution_id, outputs=outputs or {}) diff --git a/slack_bolt/context/fail/async_fail.py b/slack_bolt/context/fail/async_fail.py index af7d1a605..10a39f735 100644 --- a/slack_bolt/context/fail/async_fail.py +++ b/slack_bolt/context/fail/async_fail.py @@ -16,7 +16,7 @@ def __init__( self.client = client self.function_execution_id = function_execution_id - async def __call__(self, error: str = "") -> AsyncSlackResponse: + async def __call__(self, error: str) -> AsyncSlackResponse: """Signal that the custom function failed to complete. Kwargs: diff --git a/slack_bolt/context/fail/fail.py b/slack_bolt/context/fail/fail.py index 1bb6389fe..483bcebc3 100644 --- a/slack_bolt/context/fail/fail.py +++ b/slack_bolt/context/fail/fail.py @@ -16,7 +16,7 @@ def __init__( self.client = client self.function_execution_id = function_execution_id - def __call__(self, error: str = "") -> SlackResponse: + def __call__(self, error: str) -> SlackResponse: """Signal that the custom function failed to complete. Kwargs: From 28663c2f34f68ea78ccb29110405e5bd9eddcf4a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 7 Feb 2024 11:30:17 -0500 Subject: [PATCH 07/16] fix test with new speed updates --- tests/scenario_tests/test_function.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/scenario_tests/test_function.py b/tests/scenario_tests/test_function.py index c6b25cf08..d00898082 100644 --- a/tests/scenario_tests/test_function.py +++ b/tests/scenario_tests/test_function.py @@ -8,6 +8,7 @@ from slack_bolt.app import App from slack_bolt.request import BoltRequest from tests.mock_web_api_server import ( + assert_received_request_count, setup_mock_web_api_server, cleanup_mock_web_api_server, assert_auth_test_count, @@ -61,7 +62,7 @@ def test_valid_callback_id_success(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - assert self.mock_received_requests["/functions.completeSuccess"] == 1 + assert_received_request_count(self, "/functions.completeSuccess", 1) def test_valid_callback_id_complete(self): app = App( @@ -74,7 +75,7 @@ def test_valid_callback_id_complete(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - assert self.mock_received_requests["/functions.completeSuccess"] == 1 + assert_received_request_count(self, "/functions.completeSuccess", 1) def test_valid_callback_id_error(self): app = App( @@ -87,7 +88,7 @@ def test_valid_callback_id_error(self): response = app.dispatch(request) assert response.status == 200 assert_auth_test_count(self, 1) - assert self.mock_received_requests["/functions.completeError"] == 1 + assert_received_request_count(self, "/functions.completeError", 1) def test_invalid_callback_id(self): app = App( From b79a4c36d3bb6697f131bd573b8f3cf75e9f4c5f Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 26 Jul 2024 14:29:35 -0400 Subject: [PATCH 08/16] Improve unit test speed --- tests/scenario_tests_async/test_function.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/scenario_tests_async/test_function.py b/tests/scenario_tests_async/test_function.py index 56ff7c0b4..0aefd7774 100644 --- a/tests/scenario_tests_async/test_function.py +++ b/tests/scenario_tests_async/test_function.py @@ -9,8 +9,9 @@ from slack_bolt.app.async_app import AsyncApp from slack_bolt.request.async_request import AsyncBoltRequest from tests.mock_web_api_server import ( - setup_mock_web_api_server, - cleanup_mock_web_api_server, + assert_received_request_count_async, + setup_mock_web_api_server_async, + cleanup_mock_web_api_server_async, assert_auth_test_count_async, ) from tests.utils import remove_os_env_temporarily, restore_os_env @@ -30,11 +31,11 @@ class TestAsyncFunction: def event_loop(self): old_os_env = remove_os_env_temporarily() try: - setup_mock_web_api_server(self) + setup_mock_web_api_server_async(self) loop = asyncio.get_event_loop() yield loop loop.close() - cleanup_mock_web_api_server(self) + cleanup_mock_web_api_server_async(self) finally: restore_os_env(old_os_env) @@ -72,7 +73,7 @@ async def test_valid_callback_id_success(self): response = await app.async_dispatch(request) assert response.status == 200 await assert_auth_test_count_async(self, 1) - assert self.mock_received_requests["/functions.completeSuccess"] == 1 + await assert_received_request_count_async(self, "/functions.completeSuccess", 1) @pytest.mark.asyncio async def test_valid_callback_id_complete(self): @@ -86,7 +87,7 @@ async def test_valid_callback_id_complete(self): response = await app.async_dispatch(request) assert response.status == 200 await assert_auth_test_count_async(self, 1) - assert self.mock_received_requests["/functions.completeSuccess"] == 1 + await assert_received_request_count_async(self, "/functions.completeSuccess", 1) @pytest.mark.asyncio async def test_valid_callback_id_error(self): @@ -100,7 +101,7 @@ async def test_valid_callback_id_error(self): response = await app.async_dispatch(request) assert response.status == 200 await assert_auth_test_count_async(self, 1) - assert self.mock_received_requests["/functions.completeError"] == 1 + await assert_received_request_count_async(self, "/functions.completeError", 1) @pytest.mark.asyncio async def test_invalid_callback_id(self): From 3ab97e21cd8ea4a7e006bd21fbec805497aff620 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 1 Aug 2024 13:16:05 -0400 Subject: [PATCH 09/16] bump min version of the sdk --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4d4e8fe15..e2980e2d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -slack_sdk>=3.25.0,<4 +slack_sdk>=3.26.0,<4 From 8c5686ec6683880f05bc11f3db1795864ff185d7 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 9 Aug 2024 16:33:50 -0400 Subject: [PATCH 10/16] Update README.md --- README.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7fc50f4e7..5daeecf36 100644 --- a/README.md +++ b/README.md @@ -95,29 +95,32 @@ Apps typically react to a collection of incoming events, which can correspond to request, there's a method to build a listener function. ```python -# Listen for an event from the Events API -app.event(event_type)(fn) - -# Convenience method to listen to only `message` events using a string or re.Pattern -app.message([pattern ,])(fn) - # Listen for an action from a Block Kit element (buttons, select menus, date pickers, etc) app.action(action_id)(fn) # Listen for dialog submissions app.action({"callback_id": callbackId})(fn) -# Listen for a global or message shortcuts -app.shortcut(callback_id)(fn) - # Listen for slash commands app.command(command_name)(fn) -# Listen for view_submission modal events -app.view(callback_id)(fn) +# Listen for an event from the Events API +app.event(event_type)(fn) + +# Listen for a custom step execution from a workflow +app.function(callback_id)(fn) + +# Convenience method to listen to only `message` events using a string or re.Pattern +app.message([pattern ,])(fn) # Listen for options requests (from select menus with an external data source) app.options(action_id)(fn) + +# Listen for a global or message shortcuts +app.shortcut(callback_id)(fn) + +# Listen for view_submission modal events +app.view(callback_id)(fn) ``` The recommended way to use these methods are decorators: @@ -142,6 +145,8 @@ Most of the app's functionality will be inside listener functions (the `fn` para | `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). | `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://slack.dev/bolt-python/concepts/authenticating-oauth), or manually using the `authorize` function. | `logger` | The built-in [`logging.Logger`](https://docs.python.org/3/library/logging.html) instance you can use in middleware/listeners. +| `complete` | Utility function used to signal that a custom step failed to complete. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. +| `fail` | Utility function used to signal the successful completion of a custom step execution. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. ## Creating an async app From d7e231ce73285359e43936c5c1bd916f3234f4f5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 13 Aug 2024 10:32:27 -0400 Subject: [PATCH 11/16] Update slack_bolt/context/base_context.py Co-authored-by: Fil Maj --- slack_bolt/context/base_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 4c53d0e8c..91a040e17 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -127,7 +127,7 @@ def authorize_result(self) -> Optional[AuthorizeResult]: @property def function_bot_access_token(self) -> Optional[str]: - """The bot token resolved for this function request. Only available for function related events""" + """The bot token resolved for this function request. Only available for `function_executed` and interactivity events scoped to a custom step.""" return self.get("function_bot_access_token") @property From f6e8aaabfeb9a2a56e478c1036da9c18d0dec256 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 13 Aug 2024 10:33:48 -0400 Subject: [PATCH 12/16] Update slack_bolt/context/base_context.py Co-authored-by: Fil Maj --- slack_bolt/context/base_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 91a040e17..791d5d9a5 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -115,7 +115,7 @@ def function_execution_id(self) -> Optional[str]: @property def inputs(self) -> Optional[Dict[str, Any]]: - """The `inputs` associated with this request. Only available for function related events""" + """The `inputs` associated with this request. Only available for `function_executed` and interactivity events scoped to a custom step.""" return self.get("inputs") # -------------------------------- From a399b11322ea632d45b22106b65ee60722f41e5f Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 13 Aug 2024 10:47:28 -0400 Subject: [PATCH 13/16] improve based on feedback --- slack_bolt/app/app.py | 22 +++++++++++----------- slack_bolt/app/async_app.py | 22 +++++++++++----------- slack_bolt/context/base_context.py | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index 966cce142..eaf39d87f 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -181,7 +181,8 @@ def message_hello(message, say): `UrlVerification` is a built-in middleware that handles url_verification requests that verify the endpoint for Events API in HTTP Mode requests. attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True). - `AttachingFunctionToken` is a built-in middleware that handles tokens with function requests from Slack. + `AttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution tokens + when your app receives `function_executed` or interactivity events scoped to a custom step. ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). `SslCheck` is a built-in middleware that handles ssl_check requests from Slack. oauth_settings: The settings related to Slack app installation flow (OAuth flow) @@ -869,24 +870,23 @@ def function( ) -> Callable[..., Optional[Callable[..., Optional[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. + # Use this method as a decorator @app.function("reverse") - def reverse_string(event: dict, client: WebClient, context: BoltContext): + def reverse_string(ack: Ack, inputs: dict, complete: Complete, fail: Fail): try: - string_to_reverse = event["inputs"]["stringToReverse"] - client.functions_completeSuccess( - function_execution_id=context.function_execution_id, - outputs={"reverseString": string_to_reverse[::-1]}, - ) + ack() + string_to_reverse = inputs["stringToReverse"] + complete(outputs={"reverseString": string_to_reverse[::-1]}) except Exception as e: - client.functions_completeError( - function_execution_id=context.function_execution_id, - error=f"Cannot reverse string (error: {e})", - ) + fail(f"Cannot reverse string (error: {e})") raise e + # Pass a function to this method app.function("reverse")(reverse_string) + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.args`'s API document. + Args: callback_id: The callback id to identify the function matchers: A list of listener matcher functions. diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index a3ab96068..7e984c5d9 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -191,7 +191,8 @@ async def message_hello(message, say): # async function ssl_check_enabled: bool = False if you would like to disable the built-in middleware (Default: True). `AsyncSslCheck` is a built-in middleware that handles ssl_check requests from Slack. attaching_function_token_enabled: False if you would like to disable the built-in middleware (Default: True). - `AsyncAttachingFunctionToken` is a built-in middleware that handles tokens with function requests from Slack. + `AsyncAttachingFunctionToken` is a built-in middleware that injects the just-in-time workflow-execution token + when your app receives `function_executed` or interactivity events scoped to a custom step. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. @@ -905,24 +906,23 @@ def function( ) -> Callable[..., Optional[Callable[..., Awaitable[BoltResponse]]]]: """Registers a new Function listener. This method can be used as either a decorator or a method. + # Use this method as a decorator @app.function("reverse") - async def reverse_string(event: dict, client: AsyncWebClient, complete: AsyncComplete): + async def reverse_string(ack: AsyncAck, inputs: dict, complete: AsyncComplete, fail: AsyncFail): try: - string_to_reverse = event["inputs"]["stringToReverse"] - await client.functions_completeSuccess( - function_execution_id=context.function_execution_id, - outputs={"reverseString": string_to_reverse[::-1]}, - ) + await ack() + string_to_reverse = inputs["stringToReverse"] + await complete({"reverseString": string_to_reverse[::-1]}) except Exception as e: - await client.functions_completeError( - function_execution_id=context.function_execution_id, - error=f"Cannot reverse string (error: {e})", - ) + await fail(f"Cannot reverse string (error: {e})") raise e + # Pass a function to this method app.function("reverse")(reverse_string) + To learn available arguments for middleware/listeners, see `slack_bolt.kwargs_injection.async_args`'s API document. + Args: callback_id: The callback id to identify the function matchers: A list of listener matcher functions. diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 791d5d9a5..8484e26d1 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -110,7 +110,7 @@ def matches(self) -> Optional[Tuple]: @property def function_execution_id(self) -> Optional[str]: - """The `function_execution_id` associated with this request. Only available for function related events""" + """The `function_execution_id` associated with this request. Only available for `function_executed` and interactivity events scoped to a custom step.""" return self.get("function_execution_id") @property From 528c131223d80b99f911a35332be20f80fecffb9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 13 Aug 2024 11:06:54 -0400 Subject: [PATCH 14/16] fix linting issue --- slack_bolt/context/base_context.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 8484e26d1..a5f0e025d 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -110,12 +110,16 @@ def matches(self) -> Optional[Tuple]: @property def function_execution_id(self) -> Optional[str]: - """The `function_execution_id` associated with this request. Only available for `function_executed` and interactivity events scoped to a custom step.""" + """The `function_execution_id` associated with this request. + Only available for `function_executed` and interactivity events scoped to a custom step. + """ return self.get("function_execution_id") @property def inputs(self) -> Optional[Dict[str, Any]]: - """The `inputs` associated with this request. Only available for `function_executed` and interactivity events scoped to a custom step.""" + """The `inputs` associated with this request. + Only available for `function_executed` and interactivity events scoped to a custom step. + """ return self.get("inputs") # -------------------------------- @@ -127,7 +131,9 @@ def authorize_result(self) -> Optional[AuthorizeResult]: @property def function_bot_access_token(self) -> Optional[str]: - """The bot token resolved for this function request. Only available for `function_executed` and interactivity events scoped to a custom step.""" + """The bot token resolved for this function request. + Only available for `function_executed` and interactivity events scoped to a custom step. + """ return self.get("function_bot_access_token") @property From 78c00c2e3fcfdd5ee115a0796a075cb992414371 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 13 Aug 2024 12:23:35 -0400 Subject: [PATCH 15/16] Fix typo in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5daeecf36..d129331a1 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,8 @@ Most of the app's functionality will be inside listener functions (the `fn` para | `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). | `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://slack.dev/bolt-python/concepts/authenticating-oauth), or manually using the `authorize` function. | `logger` | The built-in [`logging.Logger`](https://docs.python.org/3/library/logging.html) instance you can use in middleware/listeners. -| `complete` | Utility function used to signal that a custom step failed to complete. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. -| `fail` | Utility function used to signal the successful completion of a custom step execution. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. +| `complete` | Utility function used to signal the successful completion of a custom step execution. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. +| `fail` | Utility function used to signal that a custom step failed to complete. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. ## Creating an async app From cd82e9ad07a080b734fa991e5559a89eb3aa71e3 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 13 Aug 2024 15:45:30 -0400 Subject: [PATCH 16/16] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d129331a1..30a62198c 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,8 @@ Most of the app's functionality will be inside listener functions (the `fn` para | `say` | Utility function to send a message to the channel associated with the incoming event. This argument is only available when the listener is triggered for events that contain a `channel_id` (the most common being `message` events). `say` accepts simple strings (for plain-text messages) and dictionaries (for messages containing blocks). | `client` | Web API client that uses the token associated with the event. For single-workspace installations, the token is provided to the constructor. For multi-workspace installations, the token is returned by using [the OAuth library](https://slack.dev/bolt-python/concepts/authenticating-oauth), or manually using the `authorize` function. | `logger` | The built-in [`logging.Logger`](https://docs.python.org/3/library/logging.html) instance you can use in middleware/listeners. -| `complete` | Utility function used to signal the successful completion of a custom step execution. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. -| `fail` | Utility function used to signal that a custom step failed to complete. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. +| `complete` | Utility function used to signal the successful completion of a custom step execution. This tells Slack to proceed with the next steps in the workflow. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. +| `fail` | Utility function used to signal that a custom step failed to complete. This tells Slack to stop the workflow execution. This argument is only available with the `.function` and `.action` listener when handling custom workflow step executions. ## Creating an async app