diff --git a/examples/async_steps_from_apps.py b/examples/async_steps_from_apps.py index e38e352bb..fef0f982c 100644 --- a/examples/async_steps_from_apps.py +++ b/examples/async_steps_from_apps.py @@ -9,7 +9,12 @@ from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient from slack_bolt.async_app import AsyncApp, AsyncAck -from slack_bolt.workflows.step.async_step import AsyncConfigure, AsyncUpdate, AsyncComplete, AsyncFail +from slack_bolt.workflows.step.async_step import ( + AsyncConfigure, + AsyncUpdate, + AsyncComplete, + AsyncFail, +) logging.basicConfig(level=logging.DEBUG) @@ -39,10 +44,7 @@ async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): "element": { "type": "plain_text_input", "action_id": "task_name", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + "placeholder": {"type": "plain_text", "text": "Write a task name",}, }, "label": {"type": "plain_text", "text": "Task name"}, }, @@ -65,10 +67,7 @@ async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): "element": { "type": "plain_text_input", "action_id": "task_author", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + "placeholder": {"type": "plain_text", "text": "Write a task name",}, }, "label": {"type": "plain_text", "text": "Task author"}, }, @@ -93,18 +92,10 @@ async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name", }, - { - "name": "taskDescription", - "type": "text", - "label": "Task Description", - }, - { - "name": "taskAuthorEmail", - "type": "text", - "label": "Task Author Email", - }, - ] + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], ) await ack() @@ -112,7 +103,9 @@ async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): pseudo_database = {} -async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail): +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): try: await complete( outputs={ @@ -121,10 +114,10 @@ async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, f "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], } ) - user: AsyncSlackResponse = await client.users_lookupByEmail( + user_lookup: AsyncSlackResponse = await client.users_lookupByEmail( email=step["inputs"]["taskAuthorEmail"]["value"] ) - user_id = user["user"]["id"] + user_id = user_lookup["user"]["id"] new_task = { "task_name": step["inputs"]["taskName"]["value"], "task_description": step["inputs"]["taskDescription"]["value"], @@ -143,7 +136,7 @@ async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, f ) blocks.append({"type": "divider"}) - home_tab_update: AsyncSlackResponse = await client.views_publish( + await client.views_publish( user_id=user_id, view={ "type": "home", @@ -151,17 +144,12 @@ async def execute(step: dict, client: AsyncWebClient, complete: AsyncComplete, f "blocks": blocks, }, ) - except: - await fail(error={ - "message": "Something wrong!" - }) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) app.step( - callback_id="copy_review", - edit=edit, - save=save, - execute=execute, + callback_id="copy_review", edit=edit, save=save, execute=execute, ) if __name__ == "__main__": diff --git a/examples/async_steps_from_apps_decorator.py b/examples/async_steps_from_apps_decorator.py new file mode 100644 index 000000000..44ca87031 --- /dev/null +++ b/examples/async_steps_from_apps_decorator.py @@ -0,0 +1,182 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import asyncio +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient +from slack_bolt.async_app import AsyncApp, AsyncAck +from slack_bolt.workflows.step.async_step import ( + AsyncConfigure, + AsyncUpdate, + AsyncComplete, + AsyncFail, + AsyncWorkflowStep, +) + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = AsyncApp() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +@copy_review_step.edit +async def edit(ack: AsyncAck, step: dict, configure: AsyncConfigure): + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +async def save(ack: AsyncAck, view: dict, update: AsyncUpdate): + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + await ack() + + +pseudo_database = {} + + + +async def additional_matcher(step): + email = str(step.get("inputs", {}).get("taskAuthorEmail")) + if "@" not in email: + return False + return True + + +async def noop_middleware(next): + return await next() + + +async def notify_execution(client: AsyncWebClient, step: dict): + await asyncio.sleep(5) + await client.chat_postMessage(channel="#random", text=f"Step execution: ```{step}```") + + +@copy_review_step.execute( + matchers=[additional_matcher], + middleware=[noop_middleware], + lazy=[notify_execution], +) +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + user_lookup: AsyncSlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user_lookup["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) + + +app.step(copy_review_step) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/examples/steps_from_apps.py b/examples/steps_from_apps.py index ab3921062..462190691 100644 --- a/examples/steps_from_apps.py +++ b/examples/steps_from_apps.py @@ -47,10 +47,7 @@ def edit(ack: Ack, step, configure: Configure): "element": { "type": "plain_text_input", "action_id": "task_name", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + "placeholder": {"type": "plain_text", "text": "Write a task name",}, }, "label": {"type": "plain_text", "text": "Task name"}, }, @@ -73,10 +70,7 @@ def edit(ack: Ack, step, configure: Configure): "element": { "type": "plain_text_input", "action_id": "task_author", - "placeholder": { - "type": "plain_text", - "text": "Write a task name", - }, + "placeholder": {"type": "plain_text", "text": "Write a task name",}, }, "label": {"type": "plain_text", "text": "Task author"}, }, @@ -101,18 +95,10 @@ def save(ack: Ack, view: dict, update: Update): }, }, outputs=[ - {"name": "taskName", "type": "text", "label": "Task Name", }, - { - "name": "taskDescription", - "type": "text", - "label": "Task Description", - }, - { - "name": "taskAuthorEmail", - "type": "text", - "label": "Task Author Email", - }, - ] + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], ) ack() @@ -161,16 +147,11 @@ def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): }, ) except Exception as err: - fail(error={ - "message": "Something wrong!" - }) + fail(error={"message": f"Something wrong! {err}"}) app.step( - callback_id="copy_review", - edit=edit, - save=save, - execute=execute, + callback_id="copy_review", edit=edit, save=save, execute=execute, ) if __name__ == "__main__": diff --git a/examples/steps_from_apps_decorator.py b/examples/steps_from_apps_decorator.py new file mode 100644 index 000000000..20b89d26d --- /dev/null +++ b/examples/steps_from_apps_decorator.py @@ -0,0 +1,182 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys +import time + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + +from slack_bolt import App, Ack +from slack_bolt.workflows.step import Configure, Update, Complete, Fail, WorkflowStep + +logging.basicConfig(level=logging.DEBUG) + +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +app = App() + + +@app.middleware # or app.use(log_request) +def log_request(logger, body, next): + logger.debug(body) + return next() + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = WorkflowStep.builder("copy_review") + + +@copy_review_step.edit +def edit(ack: Ack, step, configure: Configure): + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +def save(ack: Ack, step: dict, view: dict, update: Update): + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + ack() + + +pseudo_database = {} + + +def additional_matcher(step): + email = str(step.get("inputs", {}).get("taskAuthorEmail")) + if "@" not in email: + return False + return True + + +def noop_middleware(next): + return next() + + +def notify_execution(client: WebClient, step: dict): + time.sleep(5) + client.chat_postMessage(channel="#random", text=f"Step execution: ```{step}```") + + +@copy_review_step.execute( + matchers=[additional_matcher], + middleware=[noop_middleware], + lazy=[notify_execution], +) +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user_lookup: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user_lookup["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) + + +app.step(copy_review_step) + +if __name__ == "__main__": + app.start(3000) # POST http://localhost:3000/slack/events diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index e4946706c..1a5e673a2 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -2,9 +2,16 @@ import json import logging import os +import sys from concurrent.futures.thread import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, HTTPServer -from typing import List, Union, Pattern, Callable, Dict, Optional + +if sys.version_info.major == 3 and sys.version_info.minor <= 6: + from re import _pattern_type as Pattern +else: + from re import Pattern + +from typing import List, Union, Callable, Dict, Optional from slack_bolt.listener.thread_runner import ThreadListenerRunner from slack_bolt.workflows.step import WorkflowStep, WorkflowStepMiddleware @@ -59,6 +66,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_bolt.util.utils import create_web_client +from slack_bolt.workflows.step.step import WorkflowStepBuilder class App: @@ -357,21 +365,27 @@ def middleware(self, *args) -> Optional[Callable]: def step( self, - callback_id: Union[str, Pattern, WorkflowStep], + callback_id: Union[str, Pattern, WorkflowStep, WorkflowStepBuilder], edit: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, save: Optional[Union[Callable[..., Optional[BoltResponse]], Listener]] = None, execute: Optional[ Union[Callable[..., Optional[BoltResponse]], Listener] ] = None, ): - """Registers a new Workflow Step listener""" + """Registers a new Workflow Step listener + + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step + by a decorator, use WorkflowStepBuilder's methods. + """ step = callback_id if isinstance(callback_id, (str, Pattern)): step = WorkflowStep( callback_id=callback_id, edit=edit, save=save, execute=execute, ) + elif isinstance(step, WorkflowStepBuilder): + step = step.build() elif not isinstance(step, WorkflowStep): - raise BoltError("Invalid step object") + raise BoltError(f"Invalid step object ({type(step)})") self.use(WorkflowStepMiddleware(step, self.listener_runner)) diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 272a01747..a210cc12e 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -1,13 +1,23 @@ import inspect import logging import os -from typing import Optional, List, Union, Callable, Pattern, Dict, Awaitable +import sys + +if sys.version_info.major == 3 and sys.version_info.minor <= 6: + from re import _pattern_type as Pattern +else: + from re import Pattern + +from typing import Optional, List, Union, Callable, Dict, Awaitable from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner from slack_bolt.middleware.message_listener_matches.async_message_listener_matches import ( AsyncMessageListenerMatches, ) -from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.async_step import ( + AsyncWorkflowStep, + AsyncWorkflowStepBuilder, +) from slack_bolt.workflows.step.async_step_middleware import AsyncWorkflowStepMiddleware from slack_sdk.oauth.installation_store.async_installation_store import ( AsyncInstallationStore, @@ -380,14 +390,20 @@ def step( Union[Callable[..., Optional[BoltResponse]], AsyncListener] ] = None, ): - """Registers a new Workflow Step listener""" + """Registers a new Workflow Step listener + + Unlike others, this method doesn't behave as a decorator. If you want to register a workflow step + by a decorator, use AsyncWorkflowStepBuilder's methods. + """ step = callback_id if isinstance(callback_id, (str, Pattern)): step = AsyncWorkflowStep( callback_id=callback_id, edit=edit, save=save, execute=execute, ) + elif isinstance(step, AsyncWorkflowStepBuilder): + step = step.build() elif not isinstance(step, AsyncWorkflowStep): - raise BoltError("Invalid step object") + raise BoltError(f"Invalid step object ({type(step)})") self.use(AsyncWorkflowStepMiddleware(step, self._async_listener_runner)) diff --git a/slack_bolt/workflows/step/async_step.py b/slack_bolt/workflows/step/async_step.py index 334d98821..96fdcde15 100644 --- a/slack_bolt/workflows/step/async_step.py +++ b/slack_bolt/workflows/step/async_step.py @@ -1,4 +1,15 @@ -from typing import Callable, Union, Optional, Awaitable +import sys + +from .internals import _is_used_without_argument + +if sys.version_info.major == 3 and sys.version_info.minor <= 6: + from re import _pattern_type as Pattern +else: + from re import Pattern +from functools import wraps +from typing import Callable, Union, Optional, Awaitable, List + +from slack_sdk.web.async_client import AsyncWebClient from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener @@ -9,11 +20,250 @@ ) from slack_bolt.middleware.async_custom_middleware import AsyncCustomMiddleware from slack_bolt.response import BoltResponse -from slack_sdk.web.async_client import AsyncWebClient +from .utilities.async_complete import AsyncComplete from .utilities.async_configure import AsyncConfigure from .utilities.async_fail import AsyncFail -from .utilities.async_complete import AsyncComplete from .utilities.async_update import AsyncUpdate +from ...error import BoltError +from ...listener_matcher.async_listener_matcher import ( + AsyncListenerMatcher, + AsyncCustomListenerMatcher, +) +from ...middleware.async_middleware import AsyncMiddleware + + +class AsyncWorkflowStepBuilder: + callback_id: Union[str, Pattern] + _edit: Optional[AsyncListener] + _save: Optional[AsyncListener] + _execute: Optional[AsyncListener] + + def __init__( + self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + ): + """This builder is supposed to be used as decorator. + + my_step = AsyncWorkflowStep.builder("my_step") + + @my_step.edit + async def edit_my_step(ack, configure): + pass + + @my_step.save + async def save_my_step(ack, step, update): + pass + + @my_step.execute + async def execute_my_step(step, complete, fail): + pass + + app.step(my_step) + + :param callback_id: the callback_id for the workflow + :param app_name: the application name mainly for logging + """ + self.callback_id = callback_id + self.app_name = app_name or __name__ + self._edit = None + self._save = None + self._execute = None + + def edit( + self, + *args, + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """Register a new edit listener with details. + You can use this method as decorator as well. + + @my_step.edit + def edit_my_step(ack, configure): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.edit(matchers=[is_valid], middleware=[update_context]) + def edit_my_step(ack, configure): + pass + """ + if _is_used_without_argument(args): + func = args[0] + self._edit = self._to_listener("edit", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._edit = self._to_listener("edit", functions, matchers, middleware) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def save( + self, + *args, + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """Register a new save listener with details. + You can use this method as decorator as well. + + @my_step.save + def save_my_step(ack, step, update): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def save_my_step(ack, step, update): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._save = self._to_listener("save", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._save = self._to_listener("save", functions, matchers, middleware) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def execute( + self, + *args, + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., Awaitable[None]]]] = None, + ): + """Register a new execute listener with details. + You can use this method as decorator as well. + + @my_step.execute + def execute_my_step(step, complete, fail): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def execute_my_step(step, complete, fail): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._execute = self._to_listener("execute", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._execute = self._to_listener( + "execute", functions, matchers, middleware + ) + + @wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _inner + + def build(self) -> "AsyncWorkflowStep": + """Constructs a WorkflowStep object. This method may raise an exception + if the builder doesn't have enough configurations to build the object. + + :return: WorkflowStep object + """ + if self._edit is None: + raise BoltError(f"edit listener is not registered") + if self._save is None: + raise BoltError(f"save listener is not registered") + if self._execute is None: + raise BoltError(f"execute listener is not registered") + + return AsyncWorkflowStep( + callback_id=self.callback_id, + edit=self._edit, + save=self._save, + execute=self._execute, + app_name=self.app_name, + ) + + # --------------------------------------- + + def _to_listener( + self, + name: str, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], + matchers: Optional[ + Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher] + ] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + ) -> AsyncListener: + return AsyncWorkflowStep.build_listener( + callback_id=self.callback_id, + app_name=self.app_name, + listener_or_functions=listener_or_functions, + name=name, + matchers=self.to_listener_matchers(self.app_name, matchers), + middleware=self.to_listener_middleware(self.app_name, middleware), + ) + + @staticmethod + def to_listener_matchers( + app_name: str, + matchers: Optional[ + List[Union[Callable[..., Awaitable[bool]], AsyncListenerMatcher]] + ], + ) -> List[AsyncListenerMatcher]: + _matchers = [] + if matchers is not None: + for m in matchers: + if isinstance(m, AsyncListenerMatcher): + _matchers.append(m) + elif isinstance(m, Callable): + _matchers.append( + AsyncCustomListenerMatcher(app_name=app_name, func=m) + ) + else: + raise ValueError(f"Invalid matcher: {type(m)}") + return _matchers # type: ignore + + @staticmethod + def to_listener_middleware( + app_name: str, middleware: Optional[List[Union[Callable, AsyncMiddleware]]] + ) -> List[AsyncMiddleware]: + _middleware = [] + if middleware is not None: + for m in middleware: + if isinstance(m, AsyncMiddleware): + _middleware.append(m) + elif isinstance(m, Callable): + _middleware.append(AsyncCustomMiddleware(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid middleware: {type(m)}") + return _middleware # type: ignore class AsyncWorkflowStep: @@ -25,7 +275,7 @@ class AsyncWorkflowStep: def __init__( self, *, - callback_id: str, + callback_id: Union[str, Pattern], edit: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], save: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], execute: Union[Callable[..., Awaitable[BoltResponse]], AsyncListener], @@ -33,47 +283,73 @@ def __init__( ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self._build_listener(callback_id, app_name, edit, "edit") - self.save = self._build_listener(callback_id, app_name, save, "save") - self.execute = self._build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener(callback_id, app_name, edit, "edit") + self.save = self.build_listener(callback_id, app_name, save, "save") + self.execute = self.build_listener(callback_id, app_name, execute, "execute") + + @classmethod + def builder(cls, callback_id: Union[str, Pattern]) -> AsyncWorkflowStepBuilder: + return AsyncWorkflowStepBuilder(callback_id) @classmethod - def _build_listener( - cls, callback_id: str, app_name: str, listener: AsyncListener, name: str, + def build_listener( + cls, + callback_id: Union[str, Pattern], + app_name: str, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], + name: str, + matchers: Optional[List[AsyncListenerMatcher]] = None, + middleware: Optional[List[AsyncMiddleware]] = None, ): - if isinstance(listener, AsyncListener): - return listener - elif isinstance(listener, Callable): + if listener_or_functions is None: + raise BoltError(f"{name} listener is required (callback_id: {callback_id})") + + if isinstance(listener_or_functions, Callable): + listener_or_functions = [listener_or_functions] + + if isinstance(listener_or_functions, AsyncListener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + matchers = matchers if matchers else [] + matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + middleware = middleware if middleware else [] + middleware.insert(0, cls._build_single_middleware(name, callback_id)) + functions = listener_or_functions + ack_function = functions.pop(0) return AsyncCustomListener( app_name=app_name, - matchers=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=listener, - lazy_functions=[], + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, auto_acknowledgement=name == "execute", ) else: - raise ValueError(f"Invalid `{name}` listener") + raise BoltError( + f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})" + ) @classmethod - def _build_matchers(cls, name: str, callback_id: str): + def _build_primary_matcher( + cls, name: str, callback_id: str + ) -> AsyncListenerMatcher: if name == "edit": - return [workflow_step_edit(callback_id, asyncio=True)] + return workflow_step_edit(callback_id, asyncio=True) elif name == "save": - return [workflow_step_save(callback_id, asyncio=True)] + return workflow_step_save(callback_id, asyncio=True) elif name == "execute": - return [workflow_step_execute(callback_id, asyncio=True)] + return workflow_step_execute(callback_id, asyncio=True) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name: str, callback_id: str): + def _build_single_middleware(cls, name: str, callback_id: str) -> AsyncMiddleware: if name == "edit": - return [_build_edit_listener_middleware(callback_id)] + return _build_edit_listener_middleware(callback_id) elif name == "save": - return [_build_save_listener_middleware()] + return _build_save_listener_middleware() elif name == "execute": - return [_build_execute_listener_middleware()] + return _build_execute_listener_middleware() else: raise ValueError(f"Invalid name {name}") diff --git a/slack_bolt/workflows/step/internals.py b/slack_bolt/workflows/step/internals.py new file mode 100644 index 000000000..de032d8a0 --- /dev/null +++ b/slack_bolt/workflows/step/internals.py @@ -0,0 +1,7 @@ +def _is_used_without_argument(args): + """Tests if a decorator invocation is without () or (args). + + :param args: arguments + :return: True if it's an invocation without args + """ + return len(args) == 1 diff --git a/slack_bolt/workflows/step/step.py b/slack_bolt/workflows/step/step.py index 3d83569ca..9c5a9bf80 100644 --- a/slack_bolt/workflows/step/step.py +++ b/slack_bolt/workflows/step/step.py @@ -1,19 +1,254 @@ -from typing import Callable, Union, Optional +from functools import wraps +import sys + +from slack_bolt.workflows.step.internals import _is_used_without_argument + +if sys.version_info.major == 3 and sys.version_info.minor <= 6: + from re import _pattern_type as Pattern +else: + from re import Pattern +from typing import Callable, Union, Optional, List + +from slack_sdk.web import WebClient from slack_bolt.context import BoltContext +from slack_bolt.error import BoltError from slack_bolt.listener import Listener, CustomListener +from slack_bolt.listener_matcher import ListenerMatcher, CustomListenerMatcher from slack_bolt.listener_matcher.builtins import ( workflow_step_edit, workflow_step_save, workflow_step_execute, ) -from slack_bolt.middleware import CustomMiddleware +from slack_bolt.middleware import CustomMiddleware, Middleware from slack_bolt.response import BoltResponse from slack_bolt.workflows.step.utilities.complete import Complete from slack_bolt.workflows.step.utilities.configure import Configure from slack_bolt.workflows.step.utilities.fail import Fail from slack_bolt.workflows.step.utilities.update import Update -from slack_sdk.web import WebClient + + +class WorkflowStepBuilder: + callback_id: Union[str, Pattern] + _edit: Optional[Listener] + _save: Optional[Listener] + _execute: Optional[Listener] + + def __init__( + self, callback_id: Union[str, Pattern], app_name: Optional[str] = None, + ): + """This builder is supposed to be used as decorator. + + my_step = WorkflowStep.builder("my_step") + + @my_step.edit + def edit_my_step(ack, configure): + pass + + @my_step.save + def save_my_step(ack, step, update): + pass + + @my_step.execute + def execute_my_step(step, complete, fail): + pass + + app.step(my_step) + + :param callback_id: the callback_id for the workflow + :param app_name: the application name mainly for logging + """ + self.callback_id = callback_id + self.app_name = app_name or __name__ + self._edit = None + self._save = None + self._execute = None + + def edit( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """Register a new edit listener with details. + You can use this method as decorator as well. + + @my_step.edit + def edit_my_step(ack, configure): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.edit(matchers=[is_valid], middleware=[update_context]) + def edit_my_step(ack, configure): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._edit = self._to_listener("edit", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._edit = self._to_listener("edit", functions, matchers, middleware) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def save( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """Register a new save listener with details. + You can use this method as decorator as well. + + @my_step.save + def save_my_step(ack, step, update): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def save_my_step(ack, step, update): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._save = self._to_listener("save", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._save = self._to_listener("save", functions, matchers, middleware) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def execute( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + """Register a new execute listener with details. + You can use this method as decorator as well. + + @my_step.execute + def execute_my_step(step, complete, fail): + pass + + It's also possible to add additional listener matchers and/or middleware + + @my_step.save(matchers=[is_valid], middleware=[update_context]) + def execute_my_step(step, complete, fail): + pass + """ + + if _is_used_without_argument(args): + func = args[0] + self._execute = self._to_listener("execute", func, matchers, middleware) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._execute = self._to_listener( + "execute", functions, matchers, middleware + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def build(self) -> "WorkflowStep": + """Constructs a WorkflowStep object. This method may raise an exception + if the builder doesn't have enough configurations to build the object. + + :return: WorkflowStep object + """ + if self._edit is None: + raise BoltError(f"edit listener is not registered") + if self._save is None: + raise BoltError(f"save listener is not registered") + if self._execute is None: + raise BoltError(f"execute listener is not registered") + + return WorkflowStep( + callback_id=self.callback_id, + edit=self._edit, + save=self._save, + execute=self._execute, + app_name=self.app_name, + ) + + # --------------------------------------- + + def _to_listener( + self, + name: str, + listener_or_functions: Union[Listener, Callable, List[Callable]], + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + ) -> Listener: + return WorkflowStep.build_listener( + callback_id=self.callback_id, + app_name=self.app_name, + listener_or_functions=listener_or_functions, + name=name, + matchers=self.to_listener_matchers(self.app_name, matchers), + middleware=self.to_listener_middleware(self.app_name, middleware), + ) + + @staticmethod + def to_listener_matchers( + app_name: str, + matchers: Optional[List[Union[Callable[..., bool], ListenerMatcher]]], + ) -> List[ListenerMatcher]: + _matchers = [] + if matchers is not None: + for m in matchers: + if isinstance(m, ListenerMatcher): + _matchers.append(m) + elif isinstance(m, Callable): + _matchers.append(CustomListenerMatcher(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid matcher: {type(m)}") + return _matchers # type: ignore + + @staticmethod + def to_listener_middleware( + app_name: str, middleware: Optional[List[Union[Callable, Middleware]]] + ) -> List[Middleware]: + _middleware = [] + if middleware is not None: + for m in middleware: + if isinstance(m, Middleware): + _middleware.append(m) + elif isinstance(m, Callable): + _middleware.append(CustomMiddleware(app_name=app_name, func=m)) + else: + raise ValueError(f"Invalid middleware: {type(m)}") + return _middleware # type: ignore class WorkflowStep: @@ -25,7 +260,7 @@ class WorkflowStep: def __init__( self, *, - callback_id: str, + callback_id: Union[str, Pattern], edit: Union[Callable[..., Optional[BoltResponse]], Listener], save: Union[Callable[..., Optional[BoltResponse]], Listener], execute: Union[Callable[..., Optional[BoltResponse]], Listener], @@ -33,45 +268,71 @@ def __init__( ): self.callback_id = callback_id app_name = app_name or __name__ - self.edit = self._build_listener(callback_id, app_name, edit, "edit") - self.save = self._build_listener(callback_id, app_name, save, "save") - self.execute = self._build_listener(callback_id, app_name, execute, "execute") + self.edit = self.build_listener(callback_id, app_name, edit, "edit") + self.save = self.build_listener(callback_id, app_name, save, "save") + self.execute = self.build_listener(callback_id, app_name, execute, "execute") + + @classmethod + def builder(cls, callback_id: Union[str, Pattern]) -> WorkflowStepBuilder: + return WorkflowStepBuilder(callback_id) @classmethod - def _build_listener(cls, callback_id, app_name, listener, name): - if isinstance(listener, Listener): - return listener - elif isinstance(listener, Callable): + def build_listener( + cls, + callback_id: Union[str, Pattern], + app_name: str, + listener_or_functions: Union[Listener, Callable, List[Callable]], + name: str, + matchers: Optional[List[ListenerMatcher]] = None, + middleware: Optional[List[Middleware]] = None, + ) -> Listener: + if listener_or_functions is None: + raise BoltError(f"{name} listener is required (callback_id: {callback_id})") + + if isinstance(listener_or_functions, Callable): + listener_or_functions = [listener_or_functions] + + if isinstance(listener_or_functions, Listener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + matchers = matchers if matchers else [] + matchers.insert(0, cls._build_primary_matcher(name, callback_id)) + middleware = middleware if middleware else [] + middleware.insert(0, cls._build_single_middleware(name, callback_id)) + functions = listener_or_functions + ack_function = functions.pop(0) return CustomListener( app_name=app_name, - matchers=cls._build_matchers(name, callback_id), - middleware=cls._build_middleware(name, callback_id), - ack_function=listener, - lazy_functions=[], + matchers=matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, auto_acknowledgement=name == "execute", ) else: - raise ValueError(f"Invalid `{name}` listener") + raise BoltError( + f"Invalid {name} listener: {type(listener_or_functions)} detected (callback_id: {callback_id})" + ) @classmethod - def _build_matchers(cls, name, callback_id): + def _build_primary_matcher(cls, name, callback_id) -> ListenerMatcher: if name == "edit": - return [workflow_step_edit(callback_id)] + return workflow_step_edit(callback_id) elif name == "save": - return [workflow_step_save(callback_id)] + return workflow_step_save(callback_id) elif name == "execute": - return [workflow_step_execute(callback_id)] + return workflow_step_execute(callback_id) else: raise ValueError(f"Invalid name {name}") @classmethod - def _build_middleware(cls, name, callback_id): + def _build_single_middleware(cls, name, callback_id) -> Middleware: if name == "edit": - return [_build_edit_listener_middleware(callback_id)] + return _build_edit_listener_middleware(callback_id) elif name == "save": - return [_build_save_listener_middleware()] + return _build_save_listener_middleware() elif name == "execute": - return [_build_execute_listener_middleware()] + return _build_execute_listener_middleware() else: raise ValueError(f"Invalid name {name}") diff --git a/tests/scenario_tests/test_workflow_steps_decorator_simple.py b/tests/scenario_tests/test_workflow_steps_decorator_simple.py new file mode 100644 index 000000000..badcc2aec --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps_decorator_simple.py @@ -0,0 +1,406 @@ +import json +import time as time_module +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient, SlackResponse + +from slack_bolt import App, BoltRequest, Ack +from slack_bolt.workflows.step import Complete, Fail, Update, Configure, WorkflowStep +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWorkflowStepsDecorator: + 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) + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + + 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 test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + time_module.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +copy_review_step = WorkflowStep.builder("copy_review") + + +@copy_review_step.edit +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + ack() + + +pseudo_database = {} + + +@copy_review_step.execute +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py new file mode 100644 index 000000000..d71ca9dde --- /dev/null +++ b/tests/scenario_tests/test_workflow_steps_decorator_with_args.py @@ -0,0 +1,410 @@ +import json +import time as time_module +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient, SlackResponse + +from slack_bolt import App, BoltRequest, Ack +from slack_bolt.workflows.step import Complete, Fail, Update, Configure, WorkflowStep +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWorkflowStepsDecorator: + 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) + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step(copy_review_step) + + 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 test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request: BoltRequest = BoltRequest(body=body, headers=headers) + response = self.app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + time_module.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = App(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = self.app.dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + + +copy_review_step = WorkflowStep.builder("copy_review") + + +def noop_middleware(next): + return next() + + +@copy_review_step.edit(middleware=[noop_middleware]) +def edit(ack: Ack, step, configure: Configure): + assert step is not None + ack() + configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save(middleware=[noop_middleware]) +def save(ack: Ack, step: dict, view: dict, update: Update): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + ack() + + +pseudo_database = {} + + +@copy_review_step.execute(middleware=[noop_middleware]) +def execute(step: dict, client: WebClient, complete: Complete, fail: Fail): + assert step is not None + try: + complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps.py b/tests/scenario_tests_async/test_workflow_steps.py index de92b5b55..abbbd282a 100644 --- a/tests/scenario_tests_async/test_workflow_steps.py +++ b/tests/scenario_tests_async/test_workflow_steps.py @@ -22,7 +22,7 @@ from tests.utils import remove_os_env_temporarily, restore_os_env -class TestAsyncEvents: +class TestAsyncWorkflowSteps: signing_secret = "secret" valid_token = "xoxb-valid" mock_api_server_base_url = "http://localhost:8888" diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py new file mode 100644 index 000000000..bfd535466 --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_simple.py @@ -0,0 +1,425 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncWorkflowStepsDecorator: + 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) + self.app = AsyncApp( + client=self.web_client, signing_secret=self.signing_secret + ) + self.app.step(copy_review_step) + + 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, + ) + + @pytest.mark.asyncio + async def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +@copy_review_step.edit +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + await ack() + + +pseudo_database = {} + + +@copy_review_step.execute +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"}) diff --git a/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py new file mode 100644 index 000000000..2788a3663 --- /dev/null +++ b/tests/scenario_tests_async/test_workflow_steps_decorator_with_args.py @@ -0,0 +1,429 @@ +import asyncio +import json +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.workflows.step.async_step import AsyncWorkflowStep +from slack_bolt.workflows.step.utilities.async_complete import AsyncComplete +from slack_bolt.workflows.step.utilities.async_configure import AsyncConfigure +from slack_bolt.workflows.step.utilities.async_fail import AsyncFail +from slack_bolt.workflows.step.utilities.async_update import AsyncUpdate +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAsyncWorkflowStepsDecorator: + 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) + self.app = AsyncApp( + client=self.web_client, signing_secret=self.signing_secret + ) + self.app.step(copy_review_step) + + 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, + ) + + @pytest.mark.asyncio + async def test_edit(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(edit_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_save(self): + timestamp, body = str(int(time())), f"payload={quote(json.dumps(save_payload))}" + headers = { + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + @pytest.mark.asyncio + async def test_execute(self): + timestamp, body = str(int(time())), json.dumps(execute_payload) + headers = { + "content-type": ["application/json"], + "x-slack-signature": [self.generate_signature(body, timestamp)], + "x-slack-request-timestamp": [timestamp], + } + request = AsyncBoltRequest(body=body, headers=headers) + response = await self.app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) + assert self.mock_received_requests["/workflows.stepCompleted"] == 1 + + self.app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + self.app.step( + callback_id="copy_review___", edit=edit, save=save, execute=execute + ) + response = await self.app.async_dispatch(request) + assert response.status == 404 + + +edit_payload = { + "type": "workflow_step_edit", + "token": "verification-token", + "action_ts": "1601541356.268786", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "copy_review", + "trigger_id": "111.222.xxx", + "workflow_step": { + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "seratch@example.com"}, + "taskDescription": {"value": "This is the task for you!"}, + "taskName": {"value": "The important task"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + {"name": "taskDescription", "type": "text", "label": "Task Description"}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email"}, + ], + }, +} + +save_payload = { + "type": "view_submission", + "team": { + "id": "T111", + "domain": "subdomain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": { + "id": "W111", + "username": "primary-owner", + "name": "primary-owner", + "team_id": "T111", + }, + "api_app_id": "A111", + "token": "verification-token", + "trigger_id": "111.222.xxx", + "view": { + "id": "V111", + "team_id": "T111", + "type": "workflow_step", + "blocks": [ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "label": {"type": "plain_text", "text": "Task name"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + { + "type": "input", + "block_id": "task_description_input", + "label": {"type": "plain_text", "text": "Task description"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + }, + { + "type": "input", + "block_id": "task_author_input", + "label": {"type": "plain_text", "text": "Task author"}, + "optional": False, + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name"}, + }, + }, + ], + "private_metadata": "", + "callback_id": "copy_review", + "state": { + "values": { + "task_name_input": { + "task_name": { + "type": "plain_text_input", + "value": "The important task", + } + }, + "task_description_input": { + "task_description": { + "type": "plain_text_input", + "value": "This is the task for you!", + } + }, + "task_author_input": { + "task_author": { + "type": "plain_text_input", + "value": "seratch@example.com", + } + }, + } + }, + "hash": "111.zzz", + "submit_disabled": False, + "app_id": "A111", + "external_id": "", + "app_installed_team_id": "T111", + "bot_id": "B111", + }, + "response_urls": [], + "workflow_step": { + "workflow_step_edit_id": "111.222.zzz", + "workflow_id": "12345", + "step_id": "111-222-333-444-555", + }, +} + +execute_payload = { + "token": "verification-token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "type": "workflow_step_execute", + "callback_id": "copy_review", + "workflow_step": { + "workflow_step_execute_id": "zzz-execution", + "workflow_id": "12345", + "workflow_instance_id": "11111", + "step_id": "111-222-333-444-555", + "inputs": { + "taskAuthorEmail": {"value": "ksera@slack-corp.com"}, + "taskDescription": {"value": "sdfsdf"}, + "taskName": {"value": "a"}, + }, + "outputs": [ + {"name": "taskName", "type": "text", "label": "Task Name"}, + { + "name": "taskDescription", + "type": "text", + "label": "Task Description", + }, + { + "name": "taskAuthorEmail", + "type": "text", + "label": "Task Author Email", + }, + ], + }, + "event_ts": "1601541373.225894", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1601541373, +} + + +# https://api.slack.com/tutorials/workflow-builder-steps + +copy_review_step = AsyncWorkflowStep.builder("copy_review") + + +async def noop_middleware(next): + return await next() + + +@copy_review_step.edit(middleware=[noop_middleware]) +async def edit(ack: AsyncAck, step, configure: AsyncConfigure): + assert step is not None + await ack() + await configure( + blocks=[ + { + "type": "section", + "block_id": "intro-section", + "text": { + "type": "plain_text", + "text": "Create a task in one of the listed projects. The link to the task and other details will be available as variable data in later steps.", + }, + }, + { + "type": "input", + "block_id": "task_name_input", + "element": { + "type": "plain_text_input", + "action_id": "task_name", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task name"}, + }, + { + "type": "input", + "block_id": "task_description_input", + "element": { + "type": "plain_text_input", + "action_id": "task_description", + "placeholder": { + "type": "plain_text", + "text": "Write a description for your task", + }, + }, + "label": {"type": "plain_text", "text": "Task description"}, + }, + { + "type": "input", + "block_id": "task_author_input", + "element": { + "type": "plain_text_input", + "action_id": "task_author", + "placeholder": {"type": "plain_text", "text": "Write a task name",}, + }, + "label": {"type": "plain_text", "text": "Task author"}, + }, + ] + ) + + +@copy_review_step.save(middleware=[noop_middleware]) +async def save(ack: AsyncAck, step: dict, view: dict, update: AsyncUpdate): + assert step is not None + assert view is not None + state_values = view["state"]["values"] + await update( + inputs={ + "taskName": { + "value": state_values["task_name_input"]["task_name"]["value"], + }, + "taskDescription": { + "value": state_values["task_description_input"]["task_description"][ + "value" + ], + }, + "taskAuthorEmail": { + "value": state_values["task_author_input"]["task_author"]["value"], + }, + }, + outputs=[ + {"name": "taskName", "type": "text", "label": "Task Name",}, + {"name": "taskDescription", "type": "text", "label": "Task Description",}, + {"name": "taskAuthorEmail", "type": "text", "label": "Task Author Email",}, + ], + ) + await ack() + + +pseudo_database = {} + + +@copy_review_step.execute(middleware=[noop_middleware]) +async def execute( + step: dict, client: AsyncWebClient, complete: AsyncComplete, fail: AsyncFail +): + assert step is not None + try: + await complete( + outputs={ + "taskName": step["inputs"]["taskName"]["value"], + "taskDescription": step["inputs"]["taskDescription"]["value"], + "taskAuthorEmail": step["inputs"]["taskAuthorEmail"]["value"], + } + ) + + user: SlackResponse = await client.users_lookupByEmail( + email=step["inputs"]["taskAuthorEmail"]["value"] + ) + user_id = user["user"]["id"] + new_task = { + "task_name": step["inputs"]["taskName"]["value"], + "task_description": step["inputs"]["taskDescription"]["value"], + } + tasks = pseudo_database.get(user_id, []) + tasks.append(new_task) + pseudo_database[user_id] = tasks + + blocks = [] + for task in tasks: + blocks.append( + { + "type": "section", + "text": {"type": "plain_text", "text": task["task_name"]}, + } + ) + blocks.append({"type": "divider"}) + + await client.views_publish( + user_id=user_id, + view={ + "type": "home", + "title": {"type": "plain_text", "text": "Your tasks!"}, + "blocks": blocks, + }, + ) + except Exception as err: + await fail(error={"message": f"Something wrong! {err}"})