-
Notifications
You must be signed in to change notification settings - Fork 272
Add listener_runner to context object to enable developers to leverage lazy listeners in middleware #1142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add listener_runner to context object to enable developers to leverage lazy listeners in middleware #1142
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -735,7 +735,7 @@ def step( | |
| elif not isinstance(step, WorkflowStep): | ||
| raise BoltError(f"Invalid step object ({type(step)})") | ||
|
|
||
| self.use(WorkflowStepMiddleware(step, self.listener_runner)) | ||
| self.use(WorkflowStepMiddleware(step)) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While steps from apps is no longer supported, this change shows the benefit well |
||
|
|
||
| # ------------------------- | ||
| # global error handler | ||
|
|
@@ -1350,6 +1350,10 @@ def _init_context(self, req: BoltRequest): | |
| ) | ||
| req.context["client"] = client_per_request | ||
|
|
||
| # Most apps do not need this "listener_runner" instance. | ||
| # It is intended for apps that start lazy listeners from their custom global middleware. | ||
| req.context["listener_runner"] = self.listener_runner | ||
|
|
||
| @staticmethod | ||
| def _to_listener_functions( | ||
| kwargs: dict, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -174,12 +174,11 @@ def _start_lazy_function(self, lazy_func: Callable[..., Awaitable[None]], reques | |
| copied_request = self._build_lazy_request(request, func_name) | ||
| self.lazy_listener_runner.start(function=lazy_func, request=copied_request) | ||
|
|
||
| @staticmethod | ||
| def _build_lazy_request(request: AsyncBoltRequest, lazy_func_name: str) -> AsyncBoltRequest: | ||
| copied_request = create_copy(request.to_copyable()) | ||
| copied_request.method = "NONE" | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mypy detected this property is not defined in the request class and actually the property is never used |
||
| def _build_lazy_request(self, request: AsyncBoltRequest, lazy_func_name: str) -> AsyncBoltRequest: | ||
| copied_request: AsyncBoltRequest = create_copy(request.to_copyable()) | ||
| copied_request.lazy_only = True | ||
| copied_request.lazy_function_name = lazy_func_name | ||
| copied_request.context["listener_runner"] = self | ||
| return copied_request | ||
|
|
||
| def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -185,12 +185,11 @@ def _start_lazy_function(self, lazy_func: Callable[..., None], request: BoltRequ | |
| copied_request = self._build_lazy_request(request, func_name) | ||
| self.lazy_listener_runner.start(function=lazy_func, request=copied_request) | ||
|
|
||
| @staticmethod | ||
| def _build_lazy_request(request: BoltRequest, lazy_func_name: str) -> BoltRequest: | ||
| copied_request = create_copy(request.to_copyable()) | ||
| copied_request.method = "NONE" | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mypy detected this property is not defined in the request class and actually the property is never used |
||
| def _build_lazy_request(self, request: BoltRequest, lazy_func_name: str) -> BoltRequest: | ||
| copied_request: BoltRequest = create_copy(request.to_copyable()) | ||
| copied_request.lazy_only = True | ||
| copied_request.lazy_function_name = lazy_func_name | ||
| copied_request.context["listener_runner"] = self | ||
| return copied_request | ||
|
|
||
| def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,6 @@ | |
| from typing import Callable, Optional, Awaitable | ||
|
|
||
| from slack_bolt.listener.async_listener import AsyncListener | ||
| from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner | ||
| from slack_bolt.middleware.async_middleware import AsyncMiddleware | ||
| from slack_bolt.request.async_request import AsyncBoltRequest | ||
| from slack_bolt.response import BoltResponse | ||
|
|
@@ -13,9 +12,8 @@ | |
| class AsyncWorkflowStepMiddleware(AsyncMiddleware): | ||
| """Base middleware for step from app specific ones""" | ||
|
|
||
| def __init__(self, step: AsyncWorkflowStep, listener_runner: AsyncioListenerRunner): | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While this is a public class, I am sure that no developer relies on this inconvenient constructor, and more importantly steps from apps is no longer available for Workflow Builder. Thus, this minor breaking change is okay but others feel we should avoid this, I am happy to revert it.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a good thing to note, in case we get issues filed on the repo as a result. Technically from an API perspective, if I understand correctly, the current version of bolt-python is then the last one to support workflow steps from apps as-is. |
||
| def __init__(self, step: AsyncWorkflowStep): | ||
| self.step = step | ||
| self.listener_runner = listener_runner | ||
|
|
||
| async def async_process( | ||
| self, | ||
|
|
@@ -40,8 +38,8 @@ async def async_process( | |
|
|
||
| return await next() | ||
|
|
||
| @staticmethod | ||
| async def _run( | ||
| self, | ||
| listener: AsyncListener, | ||
| req: AsyncBoltRequest, | ||
| resp: BoltResponse, | ||
|
|
@@ -50,7 +48,7 @@ async def _run( | |
| if next_was_not_called: | ||
| return None | ||
|
|
||
| return await self.listener_runner.run( | ||
| return await req.context.listener_runner.run( | ||
| request=req, | ||
| response=resp, | ||
| listener_name=get_name_for_callable(listener.ack_function), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,23 @@ | ||
| import json | ||
| from time import time | ||
| import logging | ||
| from time import time, sleep | ||
| from typing import Callable, Optional | ||
|
|
||
| from slack_sdk.signature import SignatureVerifier | ||
| from slack_sdk.web import WebClient | ||
|
|
||
| from slack_bolt import BoltResponse, CustomListenerMatcher | ||
| from slack_bolt.app import App | ||
| from slack_bolt.listener import CustomListener | ||
| from slack_bolt.listener.thread_runner import ThreadListenerRunner | ||
| from slack_bolt.middleware import Middleware | ||
| from slack_bolt.request import BoltRequest | ||
| from slack_bolt.request.payload_utils import is_shortcut | ||
| from tests.mock_web_api_server import ( | ||
| setup_mock_web_api_server, | ||
| cleanup_mock_web_api_server, | ||
| assert_auth_test_count, | ||
| assert_received_request_count, | ||
| ) | ||
| from tests.utils import remove_os_env_temporarily, restore_os_env | ||
|
|
||
|
|
@@ -168,6 +176,27 @@ def __call__(self, next_): | |
| assert response.body == "acknowledged!" | ||
| assert_auth_test_count(self, 1) | ||
|
|
||
| def test_lazy_listener_middleware(self): | ||
| app = App( | ||
| client=self.web_client, | ||
| signing_secret=self.signing_secret, | ||
| ) | ||
| unmatch_middleware = LazyListenerStarter("xxxx") | ||
| app.use(unmatch_middleware) | ||
|
|
||
| response = app.dispatch(self.build_request()) | ||
| assert response.status == 404 | ||
| assert_auth_test_count(self, 1) | ||
|
|
||
| my_middleware = LazyListenerStarter("test-shortcut") | ||
| app.use(my_middleware) | ||
| response = app.dispatch(self.build_request()) | ||
| assert response.status == 200 | ||
| count = 0 | ||
| while count < 20 and my_middleware.lazy_called is False: | ||
| sleep(0.05) | ||
| assert my_middleware.lazy_called is True | ||
|
|
||
|
|
||
| def just_ack(ack): | ||
| ack("acknowledged!") | ||
|
|
@@ -183,3 +212,42 @@ def just_next(next): | |
|
|
||
| def just_next_(next_): | ||
| next_() | ||
|
|
||
|
|
||
| class LazyListenerStarter(Middleware): | ||
| lazy_called: bool | ||
| callback_id: str | ||
|
|
||
| def __init__(self, callback_id: str): | ||
| self.lazy_called = False | ||
| self.callback_id = callback_id | ||
|
|
||
| def lazy_listener(self): | ||
| self.lazy_called = True | ||
|
|
||
| def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]: | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As you can see, this is not so simple and easy, but for advanced developers who want to take full control (including myself), the enhancement by this PR would be greatly valuable.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wow this does provide full control |
||
| if is_shortcut(req.body): | ||
| listener = CustomListener( | ||
| app_name="test-app", | ||
| ack_function=just_ack, | ||
| lazy_functions=[self.lazy_listener], | ||
| matchers=[ | ||
| CustomListenerMatcher( | ||
| app_name="test-app", | ||
| func=lambda payload: payload.get("callback_id") == self.callback_id, | ||
| ) | ||
| ], | ||
| middleware=[], | ||
| base_logger=req.context.logger, | ||
| ) | ||
| if listener.matches(req=req, resp=resp): | ||
| listener_runner: ThreadListenerRunner = req.context.listener_runner | ||
| response = listener_runner.run( | ||
| request=req, | ||
| response=resp, | ||
| listener_name="test", | ||
| listener=listener, | ||
| ) | ||
| if response is not None: | ||
| return response | ||
| next() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unrelated minor bug in the script