diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index 42f05c94b..e9614fd8d 100644 --- a/slack_bolt/context/assistant/assistant_utilities.py +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -1,4 +1,3 @@ -import warnings from typing import Optional from slack_sdk.web import WebClient @@ -11,8 +10,6 @@ from .internals import has_channel_id_and_thread_ts from ..get_thread_context.get_thread_context import GetThreadContext from ..save_thread_context import SaveThreadContext -from ..set_status import SetStatus -from ..set_suggested_prompts import SetSuggestedPrompts from ..set_title import SetTitle @@ -47,28 +44,10 @@ def __init__( # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> SetStatus: - warnings.warn( - "AssistantUtilities.set_status is deprecated. " - "Use the set_status argument directly in your listener function " - "or access it via context.set_status instead.", - DeprecationWarning, - stacklevel=2, - ) - return SetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> SetTitle: return SetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> SetSuggestedPrompts: - return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> Say: def build_metadata() -> Optional[dict]: diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py index b40b2619c..6acf531ce 100644 --- a/slack_bolt/context/assistant/async_assistant_utilities.py +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -1,4 +1,3 @@ -import warnings from typing import Optional from slack_sdk.web.async_client import AsyncWebClient @@ -14,8 +13,6 @@ from .internals import has_channel_id_and_thread_ts from ..get_thread_context.async_get_thread_context import AsyncGetThreadContext from ..save_thread_context.async_save_thread_context import AsyncSaveThreadContext -from ..set_status.async_set_status import AsyncSetStatus -from ..set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from ..set_title.async_set_title import AsyncSetTitle @@ -50,28 +47,10 @@ def __init__( # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> AsyncSetStatus: - warnings.warn( - "AsyncAssistantUtilities.set_status is deprecated. " - "Use the set_status argument directly in your listener function " - "or access it via context.set_status instead.", - DeprecationWarning, - stacklevel=2, - ) - return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> AsyncSetTitle: return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts: - return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> AsyncSay: return AsyncSay( diff --git a/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py index 2079b6448..68b41858b 100644 --- a/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py @@ -7,13 +7,13 @@ class AsyncSetSuggestedPrompts: client: AsyncWebClient channel_id: str - thread_ts: str + thread_ts: Optional[str] def __init__( self, client: AsyncWebClient, channel_id: str, - thread_ts: str, + thread_ts: Optional[str] = None, ): self.client = client self.channel_id = channel_id @@ -23,6 +23,7 @@ async def __call__( self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -33,7 +34,7 @@ async def __call__( return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, ) diff --git a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py index 21ff815e1..349481df4 100644 --- a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -7,13 +7,13 @@ class SetSuggestedPrompts: client: WebClient channel_id: str - thread_ts: str + thread_ts: Optional[str] def __init__( self, client: WebClient, channel_id: str, - thread_ts: str, + thread_ts: Optional[str] = None, ): self.client = client self.channel_id = channel_id @@ -23,6 +23,7 @@ def __call__( self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -33,7 +34,7 @@ def __call__( return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, ) diff --git a/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py index 315ec2a50..ab69f5768 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py @@ -4,9 +4,17 @@ from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.request.payload_utils import ( + is_app_home_opened_event, + is_assistant_event, + is_assistant_thread_context_changed_event, + is_assistant_thread_started_event, + is_im_message_event, + to_event, +) from slack_bolt.response import BoltResponse @@ -25,32 +33,48 @@ async def async_process( next: Callable[[], Awaitable[BoltResponse]], ) -> Optional[BoltResponse]: event = to_event(req.body) - if event is not None: - if is_assistant_event(req.body): - assistant = AsyncAssistantUtilities( - payload=event, - context=req.context, - thread_context_store=self.thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - - # TODO: in the future we might want to introduce a "proper" extract_ts utility - thread_ts = req.context.thread_ts or event.get("ts") - if req.context.channel_id and thread_ts: - req.context["set_status"] = AsyncSetStatus( - client=req.context.client, - channel_id=req.context.channel_id, - thread_ts=thread_ts, - ) - req.context["say_stream"] = AsyncSayStream( - client=req.context.client, - channel=req.context.channel_id, - recipient_team_id=req.context.team_id or req.context.enterprise_id, - recipient_user_id=req.context.user_id, - thread_ts=thread_ts, - ) + if event is None: + return await next() + if req.context.channel_id is None: + return await next() + + if is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AsyncAssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = AsyncSetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = AsyncSayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return await next() diff --git a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py index 33847fd56..2d6ce7b01 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -1,11 +1,19 @@ from typing import Optional, Callable -from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore from slack_bolt.context.say_stream.say_stream import SayStream from slack_bolt.context.set_status.set_status import SetStatus +from slack_bolt.context.set_suggested_prompts.set_suggested_prompts import SetSuggestedPrompts from slack_bolt.middleware import Middleware -from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities +from slack_bolt.request.payload_utils import ( + is_app_home_opened_event, + is_assistant_event, + is_assistant_thread_context_changed_event, + is_assistant_thread_started_event, + is_im_message_event, + to_event, +) from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse @@ -19,32 +27,48 @@ def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]: event = to_event(req.body) - if event is not None: - if is_assistant_event(req.body): - assistant = AssistantUtilities( - payload=event, - context=req.context, - thread_context_store=self.thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - - # TODO: in the future we might want to introduce a "proper" extract_ts utility - thread_ts = req.context.thread_ts or event.get("ts") - if req.context.channel_id and thread_ts: - req.context["set_status"] = SetStatus( - client=req.context.client, - channel_id=req.context.channel_id, - thread_ts=thread_ts, - ) - req.context["say_stream"] = SayStream( - client=req.context.client, - channel=req.context.channel_id, - recipient_team_id=req.context.team_id or req.context.enterprise_id, - recipient_user_id=req.context.user_id, - thread_ts=thread_ts, - ) + if event is None: + return next() + if req.context.channel_id is None: + return next() + + if is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = SayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return next() diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 1ebf70d4f..b74238461 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -14,7 +14,7 @@ def to_event(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_event(body) and body["event"]["type"] == "message": + if is_message_event(body): return to_event(body) return None @@ -31,6 +31,25 @@ def is_workflow_step_execute(body: Dict[str, Any]) -> bool: return is_event(body) and body["event"]["type"] == "workflow_step_execute" and "workflow_step" in body["event"] +def is_message_event(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "message" + return False + + +def is_any_im_message_event(body: Dict[str, Any]) -> bool: + if is_message_event(body): + # Any message event with no subtype or any subtype (message_changed, message_deleted, etc.) + return body["event"].get("channel_type") == "im" + return False + + +def is_im_message_event(body: Dict[str, Any]) -> bool: + if is_any_im_message_event(body): + return body["event"].get("subtype") in (None, "file_share") + return False + + def is_assistant_event(body: Dict[str, Any]) -> bool: return is_event(body) and ( is_assistant_thread_started_event(body) @@ -52,28 +71,24 @@ def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: return False -def is_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): - return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im" +def is_app_home_opened_event(body: Dict[str, Any], tab: Optional[str] = None) -> bool: + if is_event(body) and body["event"]["type"] == "app_home_opened": + if tab is not None: + return body["event"].get("tab") == tab + return True return False def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): - return ( - is_message_event_in_assistant_thread(body) - and body["event"].get("subtype") in (None, "file_share") - and body["event"].get("thread_ts") is not None - and body["event"].get("bot_id") is None - ) + if is_im_message_event(body): + return body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is None return False def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): + if is_any_im_message_event(body): return ( - is_message_event_in_assistant_thread(body) - and body["event"].get("subtype") is None + body["event"].get("subtype") is None and body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is not None ) @@ -82,14 +97,10 @@ def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) -> bool: # message_changed, message_deleted etc. - if is_event(body): - return ( - is_message_event_in_assistant_thread(body) - and not is_user_message_event_in_assistant_thread(body) - and ( - _is_other_message_sub_event(body["event"].get("message")) - or _is_other_message_sub_event(body["event"].get("previous_message")) - ) + if is_any_im_message_event(body): + return not is_user_message_event_in_assistant_thread(body) and ( + _is_other_message_sub_event(body["event"].get("message")) + or _is_other_message_sub_event(body["event"].get("previous_message")) ) return False diff --git a/tests/slack_bolt/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py index 792b974b5..a743d5656 100644 --- a/tests/slack_bolt/context/test_set_suggested_prompts.py +++ b/tests/slack_bolt/context/test_set_suggested_prompts.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock, patch + import pytest from slack_sdk import WebClient from slack_sdk.web import SlackResponse @@ -31,6 +33,47 @@ def test_set_suggested_prompts_objects(self): ) assert response.status_code == 200 + def test_set_suggested_prompts_without_thread_ts(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"]) + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts=None, + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + def test_set_suggested_prompts_thread_ts_override(self): + # The call-time thread_ts must win over the stored one + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="999.999") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="123.123", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + def test_set_suggested_prompts_thread_ts_override_falsy(self): + # An explicitly passed falsy thread_ts must be forwarded, not swallowed by the stored value + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"], thread_ts="") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + def test_set_suggested_prompts_invalid(self): set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") with pytest.raises(TypeError): diff --git a/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py index b7785eb50..3f46a6b67 100644 --- a/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py +++ b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py @@ -4,6 +4,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from tests.scenario_tests.test_events_assistant import ( + build_payload, thread_started_event_body, user_message_event_body, channel_user_message_event_body, @@ -16,6 +17,70 @@ def next(): ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") +# A top-level DM (not in a thread) is not an assistant thread, but set_suggested_prompts is still attached. +top_level_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "A top-level DM, not in a thread", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A bot-authored top-level DM is also in scope: set_suggested_prompts is attached for any IM message. +bot_im_message_event_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "A DM authored by a bot", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A file_share DM is in scope too (subtype "file_share" passes is_im_message_event). +file_share_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# Opening the Messages tab of App Home is in scope for set_suggested_prompts. +app_home_opened_messages_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +# Opening the Home tab is NOT in scope: set_suggested_prompts should not be attached. +app_home_opened_home_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + class TestAttachingConversationKwargs: def test_assistant_event_attaches_kwargs(self): @@ -46,6 +111,66 @@ def test_user_message_event_attaches_kwargs(self): assert "say_stream" in req.context assert "set_status" in req.context + def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=top_level_im_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "set_title" not in req.context + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + assert "say_stream" in req.context + assert "set_status" in req.context + + def test_bot_dm_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=bot_im_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + def test_file_share_dm_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=file_share_im_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + def test_app_home_opened_messages_tab_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=app_home_opened_messages_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "say" not in req.context + assert "set_title" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + + def test_app_home_opened_home_tab_does_not_attach_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=app_home_opened_home_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" not in req.context + def test_non_assistant_event_does_not_attach_kwargs(self): middleware = AttachingConversationKwargs() req = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") diff --git a/tests/slack_bolt/request/test_payload_utils.py b/tests/slack_bolt/request/test_payload_utils.py index f1c1d94c6..576cb390d 100644 --- a/tests/slack_bolt/request/test_payload_utils.py +++ b/tests/slack_bolt/request/test_payload_utils.py @@ -1,11 +1,15 @@ from slack_bolt.request.payload_utils import ( is_event, - is_user_message_event_in_assistant_thread, - is_bot_message_event_in_assistant_thread, - is_other_message_sub_event_in_assistant_thread, + is_message_event, + is_any_im_message_event, + is_im_message_event, is_assistant_event, is_assistant_thread_started_event, is_assistant_thread_context_changed_event, + is_app_home_opened_event, + is_user_message_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, + is_other_message_sub_event_in_assistant_thread, ) from tests.scenario_tests.test_events_assistant import ( build_payload, @@ -93,6 +97,26 @@ } ) +app_home_opened_messages_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +app_home_opened_home_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + slash_command_body = { "token": "verification_token", "command": "/test", @@ -111,6 +135,85 @@ class TestPayloadUtils: + def test_is_message_event(self): + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "slash_command": slash_command_body, + "empty_dict": {}, + } + for key, body in positives.items(): + assert is_message_event(body), f"{key} should be recognized as a message event" + for key, body in negatives.items(): + assert not is_message_event(body), f"{key} should NOT be recognized as a message event" + + def test_is_any_im_message_event(self): + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "message_changed_im": message_changed_event_body, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "slash_command": slash_command_body, + } + for key, body in positives.items(): + assert is_any_im_message_event(body), f"{key} should pass {is_any_im_message_event.__name__}" + for key, body in negatives.items(): + assert not is_any_im_message_event(body), f"{key} should NOT pass {is_any_im_message_event.__name__}" + + def test_is_im_message_event(self): + # subtype must be None or "file_share" to pass + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "classic_bot_message_channel": classic_bot_message_event_payload, + "bot_message_channel": bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_im_message_event(body), f"{key} should pass {is_im_message_event.__name__}" + for key, body in negatives.items(): + assert not is_im_message_event(body), f"{key} should NOT pass {is_im_message_event.__name__}" + def test_is_event(self): positives = { "thread_started": thread_started_event_body, @@ -281,3 +384,25 @@ def test_is_assistant_thread_context_changed_event(self): assert not is_assistant_thread_context_changed_event( body ), f"{key} should NOT pass {is_assistant_thread_context_changed_event.__name__}" + + def test_is_app_home_opened_event(self): + assert is_app_home_opened_event(app_home_opened_messages_body) + assert is_app_home_opened_event(app_home_opened_home_body) + + assert is_app_home_opened_event(app_home_opened_messages_body, tab="messages") + assert not is_app_home_opened_event(app_home_opened_home_body, tab="messages") + + negatives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "user_message_im": user_message_event_body, + "channel_user_message": channel_user_message_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "empty_dict": {}, + } + for key, body in negatives.items(): + assert not is_app_home_opened_event(body), f"{key} should NOT pass {is_app_home_opened_event.__name__}" + assert not is_app_home_opened_event( + body, tab="messages" + ), f"{key} should NOT pass {is_app_home_opened_event.__name__} with tab='messages'" diff --git a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py index 2a09434a8..7a92ff2a3 100644 --- a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py +++ b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py @@ -1,4 +1,5 @@ import asyncio +from unittest.mock import MagicMock, patch import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -41,6 +42,65 @@ async def test_set_suggested_prompts_objects(self): ) assert response.status_code == 200 + @pytest.mark.asyncio + async def test_set_suggested_prompts_without_thread_ts(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"]) + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts=None, + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_thread_ts_override(self): + # The call-time thread_ts must win over the stored one + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="999.999") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="123.123", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_thread_ts_override_falsy(self): + # An explicitly passed falsy thread_ts must be forwarded, not swallowed by the stored value + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"], thread_ts="") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + @pytest.mark.asyncio async def test_set_suggested_prompts_invalid(self): set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") diff --git a/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py index a00b35cd3..c7da4ce93 100644 --- a/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py +++ b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py @@ -7,6 +7,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from tests.scenario_tests_async.test_events_assistant import ( + build_payload, thread_started_event_body, user_message_event_body, channel_user_message_event_body, @@ -19,6 +20,70 @@ async def next(): ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") +# A top-level DM (not in a thread) is not an assistant thread, but set_suggested_prompts is still attached. +top_level_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "A top-level DM, not in a thread", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A bot-authored top-level DM is also in scope: set_suggested_prompts is attached for any IM message. +bot_im_message_event_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "A DM authored by a bot", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A file_share DM is in scope too (subtype "file_share" passes is_im_message_event). +file_share_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# Opening the Messages tab of App Home is in scope for set_suggested_prompts. +app_home_opened_messages_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +# Opening the Home tab is NOT in scope: set_suggested_prompts should not be attached. +app_home_opened_home_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + class TestAsyncAttachingConversationKwargs: @pytest.mark.asyncio @@ -51,6 +116,71 @@ async def test_user_message_event_attaches_kwargs(self): assert "say_stream" in req.context assert "set_status" in req.context + @pytest.mark.asyncio + async def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=top_level_im_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "set_title" not in req.context + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + assert "say_stream" in req.context + assert "set_status" in req.context + + @pytest.mark.asyncio + async def test_bot_dm_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=bot_im_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + @pytest.mark.asyncio + async def test_file_share_dm_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=file_share_im_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + @pytest.mark.asyncio + async def test_app_home_opened_messages_tab_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=app_home_opened_messages_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "say" not in req.context + assert "set_title" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + + @pytest.mark.asyncio + async def test_app_home_opened_home_tab_does_not_attach_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=app_home_opened_home_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" not in req.context + @pytest.mark.asyncio async def test_non_assistant_event_does_not_attach_kwargs(self): middleware = AsyncAttachingConversationKwargs()