diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index ae7773725..a4b069d72 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -15,6 +15,7 @@ to_message, to_step, ) +from ..logger.messages import warning_skip_uncommon_arg_name def build_async_required_kwargs( @@ -65,6 +66,16 @@ def build_async_required_kwargs( if k not in all_available_args: all_available_args[k] = v + if len(required_arg_names) > 0: + # To support instance/class methods in a class for listeners/middleware, + # check if the first argument is either self or cls + first_arg_name = required_arg_names[0] + if first_arg_name in {"self", "cls"}: + required_arg_names.pop(0) + elif first_arg_name not in all_available_args.keys(): + logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) + required_arg_names.pop(0) + kwargs: Dict[str, Any] = { k: v for k, v in all_available_args.items() if k in required_arg_names } diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 0d936cb14..e10febb59 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -15,6 +15,7 @@ to_message, to_step, ) +from ..logger.messages import warning_skip_uncommon_arg_name def build_required_kwargs( @@ -65,6 +66,16 @@ def build_required_kwargs( if k not in all_available_args: all_available_args[k] = v + if len(required_arg_names) > 0: + # To support instance/class methods in a class for listeners/middleware, + # check if the first argument is either self or cls + first_arg_name = required_arg_names[0] + if first_arg_name in {"self", "cls"}: + required_arg_names.pop(0) + elif first_arg_name not in all_available_args.keys(): + logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) + required_arg_names.pop(0) + kwargs: Dict[str, Any] = { k: v for k, v in all_available_args.items() if k in required_arg_names } diff --git a/slack_bolt/logger/messages.py b/slack_bolt/logger/messages.py index d850567a3..8eba3ef19 100644 --- a/slack_bolt/logger/messages.py +++ b/slack_bolt/logger/messages.py @@ -83,6 +83,13 @@ def warning_bot_only_conflicts() -> str: ) +def warning_skip_uncommon_arg_name(arg_name: str) -> str: + return ( + f"Bolt skips injecting a value to the first keyword argument ({arg_name}). " + "If it is self/cls of a method, we recommend using the common names." + ) + + # ------------------------------- # Info # ------------------------------- diff --git a/tests/scenario_tests/test_app_using_methods_in_class.py b/tests/scenario_tests/test_app_using_methods_in_class.py new file mode 100644 index 000000000..4102c819c --- /dev/null +++ b/tests/scenario_tests/test_app_using_methods_in_class.py @@ -0,0 +1,165 @@ +import json +from time import time, sleep +from typing import Callable + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Say, Ack, BoltContext +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 TestAppUsingMethodsInClass: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def run_app_and_verify(self, app: App): + payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", + } + + timestamp, body = str(int(time())), f"payload={json.dumps(payload)}" + request: BoltRequest = BoltRequest( + body=body, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = app.dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + sleep(0.5) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + def test_class_methods(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method) + self.run_app_and_verify(app) + + def test_class_methods_uncommon_name(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method2) + self.run_app_and_verify(app) + + def test_instance_methods(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method) + self.run_app_and_verify(app) + + def test_instance_methods_uncommon_name(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method2) + self.run_app_and_verify(app) + + def test_static_methods(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.static_middleware) + app.shortcut("test-shortcut")(AwesomeClass.static_method) + self.run_app_and_verify(app) + + +class AwesomeClass: + def __init__(self, name: str): + self.name = name + + @classmethod + def class_middleware(cls, next: Callable): + next() + + def instance_middleware(self, next: Callable): + next() + + @staticmethod + def static_middleware(next): + next() + + @classmethod + def class_method(cls, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") + + @classmethod + def class_method2(xyz, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") + + def instance_method(self, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>! My name is {self.name}") + + def instance_method2(whatever, context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + + @staticmethod + def static_method(context: BoltContext, say: Say, ack: Ack): + ack() + say(f"Hello <@{context.user_id}>!") diff --git a/tests/scenario_tests_async/test_app_using_methods_in_class.py b/tests/scenario_tests_async/test_app_using_methods_in_class.py new file mode 100644 index 000000000..a1131fd35 --- /dev/null +++ b/tests/scenario_tests_async/test_app_using_methods_in_class.py @@ -0,0 +1,188 @@ +import asyncio +import json +from time import time +from typing import Callable + +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestAppUsingMethodsInClass: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + async def run_app_and_verify(self, app: AsyncApp): + payload = { + "type": "message_action", + "token": "verification_token", + "action_ts": "1583637157.207593", + "team": { + "id": "T111", + "domain": "test-test", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "name": "test-test"}, + "channel": {"id": "C111", "name": "dev"}, + "callback_id": "test-shortcut", + "trigger_id": "111.222.xxx", + "message_ts": "1583636382.000300", + "message": { + "client_msg_id": "zzzz-111-222-xxx-yyy", + "type": "message", + "text": "<@W222> test", + "user": "W111", + "ts": "1583636382.000300", + "team": "T111", + "blocks": [ + { + "type": "rich_text", + "block_id": "d7eJ", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U222"}, + {"type": "text", "text": " test"}, + ], + } + ], + } + ], + }, + "response_url": "https://hooks.slack.com/app/T111/111/xxx", + } + + timestamp, body = str(int(time())), f"payload={json.dumps(payload)}" + request: AsyncBoltRequest = AsyncBoltRequest( + body=body, + headers={ + "content-type": ["application/x-www-form-urlencoded"], + "x-slack-signature": [ + self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + ], + "x-slack-request-timestamp": [timestamp], + }, + ) + response = await app.async_dispatch(request) + assert response.status == 200 + assert self.mock_received_requests["/auth.test"] == 1 + await asyncio.sleep(0.5) # wait a bit after auto ack() + assert self.mock_received_requests["/chat.postMessage"] == 1 + + @pytest.mark.asyncio + async def test_class_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_class_methods_uncommon_name(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.class_middleware) + app.shortcut("test-shortcut")(AwesomeClass.class_method2) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_instance_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_instance_methods_uncommon_name(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method2) + await self.run_app_and_verify(app) + + @pytest.mark.asyncio + async def test_static_methods(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.use(AwesomeClass.static_middleware) + app.shortcut("test-shortcut")(AwesomeClass.static_method) + await self.run_app_and_verify(app) + + +class AwesomeClass: + def __init__(self, name: str): + self.name = name + + @classmethod + async def class_middleware(cls, next: Callable): + await next() + + async def instance_middleware(self, next: Callable): + await next() + + @staticmethod + async def static_middleware(next): + await next() + + @classmethod + async def class_method( + cls, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>!") + + @classmethod + async def class_method2( + xyz, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>!") + + async def instance_method( + self, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>! My name is {self.name}") + + async def instance_method2( + whatever, context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck + ): + await ack() + await say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + + @staticmethod + async def static_method(context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): + await ack() + await say(f"Hello <@{context.user_id}>!")