From ba8727251fc40d29124300d65ab9ce3274961672 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 2 Jul 2026 17:46:25 -0400 Subject: [PATCH 1/9] feat: make set_suggested_prompts accessible by all DMs to app --- .../async_set_suggested_prompts.py | 7 ++-- .../set_suggested_prompts.py | 7 ++-- .../async_attaching_conversation_kwargs.py | 40 +++++++++++-------- .../attaching_conversation_kwargs.py | 40 +++++++++++-------- slack_bolt/request/payload_utils.py | 20 ++++++---- .../context/test_set_suggested_prompts.py | 10 +++++ .../test_async_set_suggested_prompts.py | 12 ++++++ 7 files changed, 91 insertions(+), 45 deletions(-) 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..3b2efbbc7 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 or 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..ab3a5a2ea 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 or 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..13959435e 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,10 @@ 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_assistant_event, to_event, is_im_message_event from slack_bolt.response import BoltResponse @@ -38,19 +39,26 @@ async def async_process( 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 req.context.channel_id: + # 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 is_im_message_event(event): + req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + ) + if 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, + ) 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..f459bc065 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -4,8 +4,9 @@ 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.request.payload_utils import is_assistant_event, is_im_message_event, to_event from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse @@ -32,19 +33,26 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo 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 req.context.channel_id: + # 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 is_im_message_event(event): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts, + ) + if 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, + ) return next() diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 1ebf70d4f..6b58f8dad 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,12 @@ 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_assistant_event(body: Dict[str, Any]) -> bool: return is_event(body) and ( is_assistant_thread_started_event(body) @@ -52,16 +58,16 @@ 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_im_message_event(body: Dict[str, Any]) -> bool: + if is_message_event(body): + return body["event"].get("channel_type") == "im" 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) + is_im_message_event(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 @@ -72,7 +78,7 @@ def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: if is_event(body): return ( - is_message_event_in_assistant_thread(body) + is_im_message_event(body) and body["event"].get("subtype") is None and body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is not None @@ -84,7 +90,7 @@ 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) + is_im_message_event(body) and not is_user_message_event_in_assistant_thread(body) and ( _is_other_message_sub_event(body["event"].get("message")) diff --git a/tests/slack_bolt/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py index 792b974b5..e7dffb8fe 100644 --- a/tests/slack_bolt/context/test_set_suggested_prompts.py +++ b/tests/slack_bolt/context/test_set_suggested_prompts.py @@ -31,6 +31,16 @@ 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") + response: SlackResponse = set_suggested_prompts(prompts=["One", "Two"]) + assert response.status_code == 200 + + def test_set_suggested_prompts_thread_ts_override(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111") + response: SlackResponse = set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + assert response.status_code == 200 + 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_async/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py index 2a09434a8..ffc6eb324 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 @@ -41,6 +41,18 @@ 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") + response: AsyncSlackResponse = await set_suggested_prompts(prompts=["One", "Two"]) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_set_suggested_prompts_thread_ts_override(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111") + response: AsyncSlackResponse = await set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + assert response.status_code == 200 + @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") From af216203a1243087e70c343c5e710f8f31edf376 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 3 Jul 2026 17:27:56 -0400 Subject: [PATCH 2/9] Improve things but still stuff to do --- .../context/assistant/assistant_utilities.py | 7 +++++++ .../assistant/async_assistant_utilities.py | 7 +++++++ .../async_attaching_conversation_kwargs.py | 16 +++++++++++++--- .../attaching_conversation_kwargs.py | 16 +++++++++++++--- slack_bolt/request/payload_utils.py | 17 ++++++++++------- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index 42f05c94b..51cc53cfc 100644 --- a/slack_bolt/context/assistant/assistant_utilities.py +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -67,6 +67,13 @@ def set_title(self) -> SetTitle: @property def set_suggested_prompts(self) -> SetSuggestedPrompts: + warnings.warn( + "AssistantUtilities.set_suggested_prompts is deprecated. " + "Use the set_suggested_prompts argument directly in your listener function " + "or access it via context.set_suggested_prompts instead.", + DeprecationWarning, + stacklevel=2, + ) return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) @property diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py index b40b2619c..ec69c6781 100644 --- a/slack_bolt/context/assistant/async_assistant_utilities.py +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -70,6 +70,13 @@ def set_title(self) -> AsyncSetTitle: @property def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts: + warnings.warn( + "AsyncAssistantUtilities.set_suggested_prompts is deprecated. " + "Use the set_suggested_prompts argument directly in your listener function " + "or access it via context.set_suggested_prompts instead.", + DeprecationWarning, + stacklevel=2, + ) return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) @property 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 13959435e..9b668378c 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 @@ -7,7 +7,13 @@ 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, is_im_message_event +from slack_bolt.request.payload_utils import ( + is_assistant_event, + is_assistant_thread_context_changed_event, + is_assistant_thread_started_event, + to_event, + is_im_message_event, +) from slack_bolt.response import BoltResponse @@ -35,14 +41,18 @@ async def async_process( ) req.context["say"] = assistant.say req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts + # 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 if req.context.channel_id: # 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 is_im_message_event(event): + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + ): req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( client=req.context.client, channel_id=req.context.channel_id, 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 f459bc065..5afd4c921 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -6,7 +6,13 @@ 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, is_im_message_event, to_event +from slack_bolt.request.payload_utils import ( + 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 @@ -29,14 +35,18 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo ) req.context["say"] = assistant.say req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts + # 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 if req.context.channel_id: # 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 is_im_message_event(event): + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + ): req.context["set_suggested_prompts"] = SetSuggestedPrompts( client=req.context.client, channel_id=req.context.channel_id, diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 6b58f8dad..f9a590887 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -58,19 +58,23 @@ def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: return False -def is_im_message_event(body: Dict[str, Any]) -> bool: +def is_any_im_message_event(body: Dict[str, Any]) -> bool: if is_message_event(body): + # no subtype or 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_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: if is_event(body): return ( - is_im_message_event(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 + is_im_message_event(body) and body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is None ) return False @@ -79,7 +83,6 @@ def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: if is_event(body): return ( is_im_message_event(body) - and body["event"].get("subtype") is None and body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is not None ) @@ -90,7 +93,7 @@ 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_im_message_event(body) + is_any_im_message_event(body) and not is_user_message_event_in_assistant_thread(body) and ( _is_other_message_sub_event(body["event"].get("message")) From 3e7031b7d000357679c3fcd5bb47486b6594d856 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 6 Jul 2026 16:11:49 -0400 Subject: [PATCH 3/9] bring assistant logic to general bolt logic --- .../context/assistant/assistant_utilities.py | 46 +--------- .../assistant/async_assistant_utilities.py | 46 +--------- slack_bolt/context/assistant/internals.py | 9 -- .../async_attaching_conversation_kwargs.py | 82 +++++++++--------- .../attaching_conversation_kwargs.py | 84 ++++++++++--------- slack_bolt/request/payload_utils.py | 26 +++--- 6 files changed, 108 insertions(+), 185 deletions(-) delete mode 100644 slack_bolt/context/assistant/internals.py diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index 51cc53cfc..12978b425 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 @@ -8,12 +7,8 @@ from slack_bolt.context.context import BoltContext from slack_bolt.context.say import Say -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 class AssistantUtilities: @@ -34,48 +29,13 @@ def __init__( self.client = context.client self.thread_context_store = thread_context_store or DefaultAssistantThreadContextStore(context) - if has_channel_id_and_thread_ts(self.payload): - # assistant_thread_started - thread = self.payload["assistant_thread"] - self.channel_id = thread["channel_id"] - self.thread_ts = thread["thread_ts"] - elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: - # message event - self.channel_id = self.payload["channel"] - self.thread_ts = self.payload["thread_ts"] + if context.channel_id is not None and context.thread_ts is not None: + self.channel_id = context.channel_id + self.thread_ts = context.thread_ts else: # 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: - warnings.warn( - "AssistantUtilities.set_suggested_prompts is deprecated. " - "Use the set_suggested_prompts argument directly in your listener function " - "or access it via context.set_suggested_prompts instead.", - DeprecationWarning, - stacklevel=2, - ) - 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 ec69c6781..922cb3746 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 @@ -11,12 +10,8 @@ from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.context.say.async_say import AsyncSay -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 class AsyncAssistantUtilities: @@ -37,48 +32,13 @@ def __init__( self.client = context.client self.thread_context_store = thread_context_store or DefaultAsyncAssistantThreadContextStore(context) - if has_channel_id_and_thread_ts(self.payload): - # assistant_thread_started - thread = self.payload["assistant_thread"] - self.channel_id = thread["channel_id"] - self.thread_ts = thread["thread_ts"] - elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: - # message event - self.channel_id = self.payload["channel"] - self.thread_ts = self.payload["thread_ts"] + if context.channel_id is not None and context.thread_ts is not None: + self.channel_id = context.channel_id + self.thread_ts = context.thread_ts else: # 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: - warnings.warn( - "AsyncAssistantUtilities.set_suggested_prompts is deprecated. " - "Use the set_suggested_prompts argument directly in your listener function " - "or access it via context.set_suggested_prompts instead.", - DeprecationWarning, - stacklevel=2, - ) - return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> AsyncSay: return AsyncSay( diff --git a/slack_bolt/context/assistant/internals.py b/slack_bolt/context/assistant/internals.py deleted file mode 100644 index ee449c31b..000000000 --- a/slack_bolt/context/assistant/internals.py +++ /dev/null @@ -1,9 +0,0 @@ -def has_channel_id_and_thread_ts(payload: dict) -> bool: - """Verifies if the given payload has both channel_id and thread_ts under assistant_thread property. - This data pattern is available for assistant_* events. - """ - return ( - payload.get("assistant_thread") is not None - and payload["assistant_thread"].get("channel_id") is not None - and payload["assistant_thread"].get("thread_ts") is not None - ) 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 9b668378c..8b38115c1 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 @@ -5,6 +5,7 @@ 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.context.set_title.async_set_title import AsyncSetTitle from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.request.payload_utils import ( @@ -32,43 +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 + if event is None: + return await next() + if req.context.channel_id is None: + return await next() - if req.context.channel_id: - # 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 ( - is_im_message_event(req.body) - or is_assistant_thread_started_event(req.body) - or is_assistant_thread_context_changed_event(req.body) - ): - req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( - client=req.context.client, - channel_id=req.context.channel_id, - thread_ts=thread_ts, - ) - if 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 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["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) + ): + req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + if req.context.thread_ts: + req.context["set_title"] = AsyncSetTitle(req.context.client, req.context.channel_id, 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 5afd4c921..a2ee66ae8 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,12 @@ 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.context.set_title import SetTitle from slack_bolt.middleware import Middleware +from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.request.payload_utils import ( is_assistant_event, is_assistant_thread_context_changed_event, @@ -26,43 +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 + 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["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) + ): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + if req.context.thread_ts: + req.context["set_title"] = SetTitle(req.context.client, req.context.channel_id, req.context.thread_ts) - if req.context.channel_id: - # 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 ( - is_im_message_event(req.body) - or is_assistant_thread_started_event(req.body) - or is_assistant_thread_context_changed_event(req.body) - ): - req.context["set_suggested_prompts"] = SetSuggestedPrompts( - client=req.context.client, - channel_id=req.context.channel_id, - thread_ts=thread_ts, - ) - if 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, - ) + # 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 f9a590887..3dc544413 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -37,6 +37,19 @@ def is_message_event(body: Dict[str, Any]) -> bool: 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) @@ -58,19 +71,6 @@ def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: return False -def is_any_im_message_event(body: Dict[str, Any]) -> bool: - if is_message_event(body): - # no subtype or 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_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: if is_event(body): return ( From cbfbae6a16a752693090664667bb8f1469a2e0fa Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 7 Jul 2026 12:38:48 -0400 Subject: [PATCH 4/9] chore: fix bug --- slack_bolt/request/payload_utils.py | 3 +- .../slack_bolt/request/test_payload_utils.py | 369 ++++++++++++++++++ 2 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 tests/slack_bolt/request/test_payload_utils.py diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 3dc544413..d1dae8c7a 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -82,7 +82,8 @@ def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: if is_event(body): return ( - is_im_message_event(body) + is_any_im_message_event(body) + and body["event"].get("subtype") is None and body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is not None ) diff --git a/tests/slack_bolt/request/test_payload_utils.py b/tests/slack_bolt/request/test_payload_utils.py new file mode 100644 index 000000000..443b153d3 --- /dev/null +++ b/tests/slack_bolt/request/test_payload_utils.py @@ -0,0 +1,369 @@ +from slack_bolt.request.payload_utils import ( + is_event, + is_message_event, + is_any_im_message_event, + is_im_message_event, + is_user_message_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, + is_other_message_sub_event_in_assistant_thread, + is_assistant_event, + is_assistant_thread_started_event, + is_assistant_thread_context_changed_event, +) +from tests.scenario_tests.test_events_assistant import ( + build_payload, + thread_started_event_body, + thread_context_changed_event_body, + user_message_event_body, + user_message_event_body_with_assistant_thread, + message_changed_event_body, + channel_user_message_event_body, + channel_message_changed_event_body, +) +from tests.scenario_tests.test_message_bot import ( + bot_message_event_payload, + classic_bot_message_event_payload, +) +from tests.scenario_tests.test_message_deleted import event_payload as message_deleted_channel_body +from tests.scenario_tests.test_events_ignore_self import event_body as reaction_added_event_body +from tests.scenario_tests.test_block_actions import body as block_actions_body + +file_share_im_message_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "files": [ + { + "id": "F111", + "created": 1726133700, + "name": "test.png", + "title": "test.png", + "mimetype": "image/png", + "filetype": "png", + "user": "W222", + "size": 12345, + "mode": "hosted", + "is_external": False, + "is_public": False, + "url_private": "https://files.slack.com/files-pri/T111-F111/test.png", + "permalink": "https://example.slack.com/files/W222/F111/test.png", + } + ], + "upload": True, + "display_as_bot": False, + "thread_ts": "1726133698.626339", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +bot_im_thread_message_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "Here is your answer", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "bot_profile": { + "id": "B111", + "deleted": False, + "name": "assistant-app", + "updated": 1726133600, + "app_id": "A222", + "team_id": "T111", + }, + "thread_ts": "1726133698.626339", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +im_message_no_thread_ts_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", + } +) + +slash_command_body = { + "token": "verification_token", + "command": "/test", + "text": "hello", + "user_id": "U111", + "user_name": "primary-owner", + "channel_id": "C111", + "channel_name": "test-channel", + "team_id": "T111", + "team_domain": "test-domain", + "api_app_id": "A111", + "is_enterprise_install": "false", + "response_url": "https://hooks.slack.com/commands/T111/111/xxx", + "trigger_id": "111.222.xxx", +} + + +class TestPayloadUtils: + def test_is_event(self): + positives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "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, + "reaction_added": reaction_added_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 = { + "block_actions": block_actions_body, + "slash_command": slash_command_body, + "empty_dict": {}, + } + for key, body in positives.items(): + assert is_event(body), f"{key} should be recognized as an event" + for key, body in negatives.items(): + assert not is_event(body), f"{key} should NOT be recognized as an event" + + 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_user_message_event_in_assistant_thread(self): + # Requires: is_im_message_event + thread_ts present + bot_id absent + 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, + } + negatives = { + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + "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, + "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_user_message_event_in_assistant_thread( + body + ), f"{key} should pass {is_user_message_event_in_assistant_thread.__name__}" + for key, body in negatives.items(): + assert not is_user_message_event_in_assistant_thread( + body + ), f"{key} should NOT pass {is_user_message_event_in_assistant_thread.__name__}" + + def test_is_bot_message_event_in_assistant_thread(self): + # Requires: is_any_im_message_event + subtype is None + thread_ts present + bot_id present + positives = { + "bot_im_thread": bot_im_thread_message_body, + } + negatives = { + "user_message_im": user_message_event_body, + # file_share_im has subtype="file_share" -- the key user/bot asymmetry + "file_share_im": file_share_im_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "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_bot_message_event_in_assistant_thread( + body + ), f"{key} should pass {is_bot_message_event_in_assistant_thread.__name__}" + for key, body in negatives.items(): + assert not is_bot_message_event_in_assistant_thread( + body + ), f"{key} should NOT pass {is_bot_message_event_in_assistant_thread.__name__}" + + def test_is_bot_message_user_message_asymmetry(self): + # file_share in IM thread: user predicate accepts it, bot predicate rejects it + assert is_user_message_event_in_assistant_thread(file_share_im_message_body) + assert not is_bot_message_event_in_assistant_thread(file_share_im_message_body) + + # bot message in IM thread: bot predicate accepts it, user predicate rejects it + assert is_bot_message_event_in_assistant_thread(bot_im_thread_message_body) + assert not is_user_message_event_in_assistant_thread(bot_im_thread_message_body) + + def test_is_other_message_sub_event_in_assistant_thread(self): + positives = { + "message_changed_im": message_changed_event_body, + } + negatives = { + "user_message_im": user_message_event_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + "channel_message_changed": channel_message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_other_message_sub_event_in_assistant_thread( + body + ), f"{key} should pass {is_other_message_sub_event_in_assistant_thread.__name__}" + for key, body in negatives.items(): + assert not is_other_message_sub_event_in_assistant_thread( + body + ), f"{key} should NOT pass {is_other_message_sub_event_in_assistant_thread.__name__}" + + def test_is_assistant_event(self): + positives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "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, + } + negatives = { + "message_changed_im": message_changed_event_body, + "im_no_thread_ts": im_message_no_thread_ts_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, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_assistant_event(body), f"{key} should pass {is_assistant_event.__name__}" + for key, body in negatives.items(): + assert not is_assistant_event(body), f"{key} should NOT pass {is_assistant_event.__name__}" + + def test_is_assistant_thread_started_event(self): + assert is_assistant_thread_started_event(thread_started_event_body) + + negatives = { + "thread_context_changed": thread_context_changed_event_body, + "user_message_im": user_message_event_body, + "message_changed_im": message_changed_event_body, + "bot_im_thread": bot_im_thread_message_body, + "channel_user_message": channel_user_message_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in negatives.items(): + assert not is_assistant_thread_started_event( + body + ), f"{key} should NOT pass {is_assistant_thread_started_event.__name__}" + + def test_is_assistant_thread_context_changed_event(self): + assert is_assistant_thread_context_changed_event(thread_context_changed_event_body) + + negatives = { + "thread_started": thread_started_event_body, + "user_message_im": user_message_event_body, + "message_changed_im": message_changed_event_body, + "bot_im_thread": bot_im_thread_message_body, + "channel_user_message": channel_user_message_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in negatives.items(): + assert not is_assistant_thread_context_changed_event( + body + ), f"{key} should NOT pass {is_assistant_thread_context_changed_event.__name__}" From 07d70efc7e7eb56aa09936a19e483aad709c07bf Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 7 Jul 2026 15:03:33 -0400 Subject: [PATCH 5/9] test: trim test_payload_utils to new-behavior tests only The characterization tests for the pre-existing assistant predicates, and the shared scenario fixture improvements, were moved to a separate branch (PR #1548) so they can land on main first as a regression net. This branch now keeps only the tests for behavior it introduces: the new is_message_event, is_any_im_message_event, and is_im_message_event predicates. Co-Authored-By: Claude --- .../slack_bolt/request/test_payload_utils.py | 182 ------------------ 1 file changed, 182 deletions(-) diff --git a/tests/slack_bolt/request/test_payload_utils.py b/tests/slack_bolt/request/test_payload_utils.py index 443b153d3..fdce9ed01 100644 --- a/tests/slack_bolt/request/test_payload_utils.py +++ b/tests/slack_bolt/request/test_payload_utils.py @@ -1,14 +1,7 @@ from slack_bolt.request.payload_utils import ( - is_event, is_message_event, is_any_im_message_event, is_im_message_event, - is_user_message_event_in_assistant_thread, - is_bot_message_event_in_assistant_thread, - is_other_message_sub_event_in_assistant_thread, - is_assistant_event, - is_assistant_thread_started_event, - is_assistant_thread_context_changed_event, ) from tests.scenario_tests.test_events_assistant import ( build_payload, @@ -114,33 +107,6 @@ class TestPayloadUtils: - def test_is_event(self): - positives = { - "thread_started": thread_started_event_body, - "thread_context_changed": thread_context_changed_event_body, - "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, - "reaction_added": reaction_added_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 = { - "block_actions": block_actions_body, - "slash_command": slash_command_body, - "empty_dict": {}, - } - for key, body in positives.items(): - assert is_event(body), f"{key} should be recognized as an event" - for key, body in negatives.items(): - assert not is_event(body), f"{key} should NOT be recognized as an event" - def test_is_message_event(self): positives = { "user_message_im": user_message_event_body, @@ -219,151 +185,3 @@ def test_is_im_message_event(self): 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_user_message_event_in_assistant_thread(self): - # Requires: is_im_message_event + thread_ts present + bot_id absent - 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, - } - negatives = { - "bot_im_thread": bot_im_thread_message_body, - "im_no_thread_ts": im_message_no_thread_ts_body, - "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, - "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_user_message_event_in_assistant_thread( - body - ), f"{key} should pass {is_user_message_event_in_assistant_thread.__name__}" - for key, body in negatives.items(): - assert not is_user_message_event_in_assistant_thread( - body - ), f"{key} should NOT pass {is_user_message_event_in_assistant_thread.__name__}" - - def test_is_bot_message_event_in_assistant_thread(self): - # Requires: is_any_im_message_event + subtype is None + thread_ts present + bot_id present - positives = { - "bot_im_thread": bot_im_thread_message_body, - } - negatives = { - "user_message_im": user_message_event_body, - # file_share_im has subtype="file_share" -- the key user/bot asymmetry - "file_share_im": file_share_im_message_body, - "im_no_thread_ts": im_message_no_thread_ts_body, - "message_changed_im": message_changed_event_body, - "channel_user_message": channel_user_message_event_body, - "bot_message_channel": bot_message_event_payload, - "classic_bot_message_channel": classic_bot_message_event_payload, - "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_bot_message_event_in_assistant_thread( - body - ), f"{key} should pass {is_bot_message_event_in_assistant_thread.__name__}" - for key, body in negatives.items(): - assert not is_bot_message_event_in_assistant_thread( - body - ), f"{key} should NOT pass {is_bot_message_event_in_assistant_thread.__name__}" - - def test_is_bot_message_user_message_asymmetry(self): - # file_share in IM thread: user predicate accepts it, bot predicate rejects it - assert is_user_message_event_in_assistant_thread(file_share_im_message_body) - assert not is_bot_message_event_in_assistant_thread(file_share_im_message_body) - - # bot message in IM thread: bot predicate accepts it, user predicate rejects it - assert is_bot_message_event_in_assistant_thread(bot_im_thread_message_body) - assert not is_user_message_event_in_assistant_thread(bot_im_thread_message_body) - - def test_is_other_message_sub_event_in_assistant_thread(self): - positives = { - "message_changed_im": message_changed_event_body, - } - negatives = { - "user_message_im": user_message_event_body, - "bot_im_thread": bot_im_thread_message_body, - "im_no_thread_ts": im_message_no_thread_ts_body, - "channel_message_changed": channel_message_changed_event_body, - "channel_user_message": channel_user_message_event_body, - "message_deleted_channel": message_deleted_channel_body, - "thread_started": thread_started_event_body, - "reaction_added": reaction_added_event_body, - "block_actions": block_actions_body, - } - for key, body in positives.items(): - assert is_other_message_sub_event_in_assistant_thread( - body - ), f"{key} should pass {is_other_message_sub_event_in_assistant_thread.__name__}" - for key, body in negatives.items(): - assert not is_other_message_sub_event_in_assistant_thread( - body - ), f"{key} should NOT pass {is_other_message_sub_event_in_assistant_thread.__name__}" - - def test_is_assistant_event(self): - positives = { - "thread_started": thread_started_event_body, - "thread_context_changed": thread_context_changed_event_body, - "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, - } - negatives = { - "message_changed_im": message_changed_event_body, - "im_no_thread_ts": im_message_no_thread_ts_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, - "reaction_added": reaction_added_event_body, - "block_actions": block_actions_body, - } - for key, body in positives.items(): - assert is_assistant_event(body), f"{key} should pass {is_assistant_event.__name__}" - for key, body in negatives.items(): - assert not is_assistant_event(body), f"{key} should NOT pass {is_assistant_event.__name__}" - - def test_is_assistant_thread_started_event(self): - assert is_assistant_thread_started_event(thread_started_event_body) - - negatives = { - "thread_context_changed": thread_context_changed_event_body, - "user_message_im": user_message_event_body, - "message_changed_im": message_changed_event_body, - "bot_im_thread": bot_im_thread_message_body, - "channel_user_message": channel_user_message_event_body, - "reaction_added": reaction_added_event_body, - "block_actions": block_actions_body, - } - for key, body in negatives.items(): - assert not is_assistant_thread_started_event( - body - ), f"{key} should NOT pass {is_assistant_thread_started_event.__name__}" - - def test_is_assistant_thread_context_changed_event(self): - assert is_assistant_thread_context_changed_event(thread_context_changed_event_body) - - negatives = { - "thread_started": thread_started_event_body, - "user_message_im": user_message_event_body, - "message_changed_im": message_changed_event_body, - "bot_im_thread": bot_im_thread_message_body, - "channel_user_message": channel_user_message_event_body, - "reaction_added": reaction_added_event_body, - "block_actions": block_actions_body, - } - for key, body in negatives.items(): - assert not is_assistant_thread_context_changed_event( - body - ), f"{key} should NOT pass {is_assistant_thread_context_changed_event.__name__}" From 19fced4144f6f07a440abe26542980b2dfe4240b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 8 Jul 2026 16:54:39 -0400 Subject: [PATCH 6/9] refactor: scope set_title to assistant threads via AssistantUtilities Move set_title construction into AssistantUtilities as a property and attach it from the assistant-event branch of AttachingConversationKwargs (sync and async). Previously set_title was attached to any IM message event that had a thread_ts; it is now assistant-thread-only, matching say/get_thread_context/save_thread_context. set_suggested_prompts remains available for any DM to the app. AssistantUtilities now derives channel_id/thread_ts directly from the payload: from the assistant_thread property for assistant_* events (via the new has_channel_id_and_thread_ts helper in internals.py) and from channel/thread_ts for message events, instead of reading them off the BoltContext. Also simplify the assistant-thread message predicates in payload_utils, since is_im_message_event / is_any_im_message_event already imply is_event. Co-Authored-By: Claude --- .../context/assistant/assistant_utilities.py | 18 ++++++++-- .../assistant/async_assistant_utilities.py | 18 ++++++++-- slack_bolt/context/assistant/internals.py | 9 +++++ .../async_attaching_conversation_kwargs.py | 6 ++-- .../attaching_conversation_kwargs.py | 4 +-- slack_bolt/request/payload_utils.py | 23 +++++------- .../test_attaching_conversation_kwargs.py | 34 ++++++++++++++++++ ...est_async_attaching_conversation_kwargs.py | 35 +++++++++++++++++++ 8 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 slack_bolt/context/assistant/internals.py diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index 12978b425..ea32287ba 100644 --- a/slack_bolt/context/assistant/assistant_utilities.py +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -7,8 +7,10 @@ from slack_bolt.context.context import BoltContext from slack_bolt.context.say import Say +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_title import SetTitle class AssistantUtilities: @@ -29,9 +31,15 @@ def __init__( self.client = context.client self.thread_context_store = thread_context_store or DefaultAssistantThreadContextStore(context) - if context.channel_id is not None and context.thread_ts is not None: - self.channel_id = context.channel_id - self.thread_ts = context.thread_ts + if has_channel_id_and_thread_ts(self.payload): + # assistant_thread_started + thread = self.payload["assistant_thread"] + self.channel_id = thread["channel_id"] + self.thread_ts = thread["thread_ts"] + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self.channel_id = self.payload["channel"] + self.thread_ts = self.payload["thread_ts"] else: # 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})") @@ -51,6 +59,10 @@ def build_metadata() -> Optional[dict]: build_metadata=build_metadata, ) + @property + def set_title(self) -> SetTitle: + return SetTitle(self.client, self.channel_id, self.thread_ts) + @property def get_thread_context(self) -> GetThreadContext: return GetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload) diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py index 922cb3746..608f515b8 100644 --- a/slack_bolt/context/assistant/async_assistant_utilities.py +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -10,8 +10,10 @@ from slack_bolt.context.async_context import AsyncBoltContext from slack_bolt.context.say.async_say import AsyncSay +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_title.async_set_title import AsyncSetTitle class AsyncAssistantUtilities: @@ -32,9 +34,15 @@ def __init__( self.client = context.client self.thread_context_store = thread_context_store or DefaultAsyncAssistantThreadContextStore(context) - if context.channel_id is not None and context.thread_ts is not None: - self.channel_id = context.channel_id - self.thread_ts = context.thread_ts + if has_channel_id_and_thread_ts(self.payload): + # assistant_thread_started + thread = self.payload["assistant_thread"] + self.channel_id = thread["channel_id"] + self.thread_ts = thread["thread_ts"] + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self.channel_id = self.payload["channel"] + self.thread_ts = self.payload["thread_ts"] else: # 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})") @@ -48,6 +56,10 @@ def say(self) -> AsyncSay: build_metadata=self._build_message_metadata, ) + @property + def set_title(self) -> AsyncSetTitle: + return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) + async def _build_message_metadata(self) -> dict: return { "event_type": "assistant_thread_context", diff --git a/slack_bolt/context/assistant/internals.py b/slack_bolt/context/assistant/internals.py new file mode 100644 index 000000000..ee449c31b --- /dev/null +++ b/slack_bolt/context/assistant/internals.py @@ -0,0 +1,9 @@ +def has_channel_id_and_thread_ts(payload: dict) -> bool: + """Verifies if the given payload has both channel_id and thread_ts under assistant_thread property. + This data pattern is available for assistant_* events. + """ + return ( + payload.get("assistant_thread") is not None + and payload["assistant_thread"].get("channel_id") is not None + and payload["assistant_thread"].get("thread_ts") is not None + ) 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 8b38115c1..6252c133b 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 @@ -5,15 +5,14 @@ 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.context.set_title.async_set_title import AsyncSetTitle 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, is_assistant_thread_context_changed_event, is_assistant_thread_started_event, - to_event, is_im_message_event, + to_event, ) from slack_bolt.response import BoltResponse @@ -46,6 +45,7 @@ async def async_process( 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 @@ -59,8 +59,6 @@ async def async_process( channel_id=req.context.channel_id, thread_ts=req.context.thread_ts, ) - if req.context.thread_ts: - req.context["set_title"] = AsyncSetTitle(req.context.client, req.context.channel_id, 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") 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 a2ee66ae8..727a7f43b 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -4,7 +4,6 @@ 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.context.set_title import SetTitle from slack_bolt.middleware import Middleware from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.request.payload_utils import ( @@ -40,6 +39,7 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo 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 @@ -53,8 +53,6 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo channel_id=req.context.channel_id, thread_ts=req.context.thread_ts, ) - if req.context.thread_ts: - req.context["set_title"] = SetTitle(req.context.client, req.context.channel_id, 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") diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index d1dae8c7a..dc1219a62 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -72,18 +72,15 @@ def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): - return ( - is_im_message_event(body) 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_any_im_message_event(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 ) @@ -92,14 +89,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_any_im_message_event(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/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py index b7785eb50..c7fbb8196 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,19 @@ 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", + } +) + class TestAttachingConversationKwargs: def test_assistant_event_attaches_kwargs(self): @@ -46,6 +60,26 @@ 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 + # set_suggested_prompts is available for any DM to the app + assert "set_suggested_prompts" in req.context + # set_title is assistant-thread-only; a top-level DM is not an assistant thread + assert "set_title" not in req.context + # say/get_thread_context/save_thread_context remain assistant-only + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + # set_status / say_stream are attached whenever a ts is resolvable + assert "say_stream" in req.context + assert "set_status" 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_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..c8a68624c 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,19 @@ 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", + } +) + class TestAsyncAttachingConversationKwargs: @pytest.mark.asyncio @@ -51,6 +65,27 @@ 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 + # set_suggested_prompts is available for any DM to the app + assert "set_suggested_prompts" in req.context + # set_title is assistant-thread-only; a top-level DM is not an assistant thread + assert "set_title" not in req.context + # say/get_thread_context/save_thread_context remain assistant-only + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + # set_status / say_stream are attached whenever a ts is resolvable + assert "say_stream" in req.context + assert "set_status" in req.context + @pytest.mark.asyncio async def test_non_assistant_event_does_not_attach_kwargs(self): middleware = AsyncAttachingConversationKwargs() From 8f33d65ad34daad2e5533f2220c17272109cc56f Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 8 Jul 2026 17:13:38 -0400 Subject: [PATCH 7/9] chore: clean up pr --- slack_bolt/context/assistant/assistant_utilities.py | 8 ++++---- slack_bolt/context/assistant/async_assistant_utilities.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index ea32287ba..e9614fd8d 100644 --- a/slack_bolt/context/assistant/assistant_utilities.py +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -44,6 +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})") + @property + def set_title(self) -> SetTitle: + return SetTitle(self.client, self.channel_id, self.thread_ts) + @property def say(self) -> Say: def build_metadata() -> Optional[dict]: @@ -59,10 +63,6 @@ def build_metadata() -> Optional[dict]: build_metadata=build_metadata, ) - @property - def set_title(self) -> SetTitle: - return SetTitle(self.client, self.channel_id, self.thread_ts) - @property def get_thread_context(self) -> GetThreadContext: return GetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload) diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py index 608f515b8..6acf531ce 100644 --- a/slack_bolt/context/assistant/async_assistant_utilities.py +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -47,6 +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})") + @property + def set_title(self) -> AsyncSetTitle: + return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) + @property def say(self) -> AsyncSay: return AsyncSay( @@ -56,10 +60,6 @@ def say(self) -> AsyncSay: build_metadata=self._build_message_metadata, ) - @property - def set_title(self) -> AsyncSetTitle: - return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) - async def _build_message_metadata(self) -> dict: return { "event_type": "assistant_thread_context", From 25768ba0df4b721623dd20171f27b14fb6108e51 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 9 Jul 2026 14:52:50 -0400 Subject: [PATCH 8/9] fis: improve based on code review --- .../async_set_suggested_prompts.py | 2 +- .../set_suggested_prompts.py | 2 +- .../context/test_set_suggested_prompts.py | 43 ++++++++++++-- .../test_attaching_conversation_kwargs.py | 52 ++++++++++++++++- .../test_async_set_suggested_prompts.py | 58 +++++++++++++++++-- ...est_async_attaching_conversation_kwargs.py | 53 +++++++++++++++++ 6 files changed, 197 insertions(+), 13 deletions(-) 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 3b2efbbc7..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 @@ -34,7 +34,7 @@ async def __call__( return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=thread_ts or 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 ab3a5a2ea..349481df4 100644 --- a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -34,7 +34,7 @@ def __call__( return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=thread_ts or 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/tests/slack_bolt/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py index e7dffb8fe..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 @@ -33,13 +35,44 @@ def test_set_suggested_prompts_objects(self): def test_set_suggested_prompts_without_thread_ts(self): set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111") - response: SlackResponse = set_suggested_prompts(prompts=["One", "Two"]) - assert response.status_code == 200 + 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): - set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111") - response: SlackResponse = set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") - assert response.status_code == 200 + # 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") 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 c7fbb8196..6c181e4bc 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 @@ -30,6 +30,35 @@ def next(): } ) +# 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", + } +) + class TestAttachingConversationKwargs: def test_assistant_event_attaches_kwargs(self): @@ -68,7 +97,6 @@ def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - # set_suggested_prompts is available for any DM to the app assert "set_suggested_prompts" in req.context # set_title is assistant-thread-only; a top-level DM is not an assistant thread assert "set_title" not in req.context @@ -80,6 +108,28 @@ def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): assert "say_stream" in req.context assert "set_status" in req.context + def test_bot_dm_attaches_suggested_prompts(self): + # set_suggested_prompts is intentionally attached for any IM message, including bot-authored DMs. + 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): + # A file_share DM is in scope for set_suggested_prompts. + 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_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_async/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py index ffc6eb324..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 @@ -44,14 +45,61 @@ async def test_set_suggested_prompts_objects(self): @pytest.mark.asyncio async def test_set_suggested_prompts_without_thread_ts(self): set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111") - response: AsyncSlackResponse = await set_suggested_prompts(prompts=["One", "Two"]) - assert response.status_code == 200 + 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): - set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111") - response: AsyncSlackResponse = await set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") - assert response.status_code == 200 + # 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): 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 c8a68624c..00e0da6fb 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 @@ -33,6 +33,35 @@ async def next(): } ) +# 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", + } +) + class TestAsyncAttachingConversationKwargs: @pytest.mark.asyncio @@ -86,6 +115,30 @@ async def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): assert "say_stream" in req.context assert "set_status" in req.context + @pytest.mark.asyncio + async def test_bot_dm_attaches_suggested_prompts(self): + # set_suggested_prompts is intentionally attached for any IM message, including bot-authored DMs. + 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): + # A file_share DM is in scope for set_suggested_prompts. + 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_non_assistant_event_does_not_attach_kwargs(self): middleware = AsyncAttachingConversationKwargs() From 812f7c3d03a660675a19b9cadc8aea572726bcf7 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 9 Jul 2026 15:25:18 -0400 Subject: [PATCH 9/9] feat: attach set_suggested_prompts on app_home_opened Messages tab --- .../async_attaching_conversation_kwargs.py | 2 + .../attaching_conversation_kwargs.py | 2 + slack_bolt/request/payload_utils.py | 8 +++ .../test_attaching_conversation_kwargs.py | 51 ++++++++++++++++-- .../slack_bolt/request/test_payload_utils.py | 43 +++++++++++++++ ...est_async_attaching_conversation_kwargs.py | 54 ++++++++++++++++--- 6 files changed, 149 insertions(+), 11 deletions(-) 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 6252c133b..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 @@ -8,6 +8,7 @@ from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest 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, @@ -53,6 +54,7 @@ async def async_process( 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, 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 727a7f43b..2d6ce7b01 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -7,6 +7,7 @@ from slack_bolt.middleware import Middleware 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, @@ -47,6 +48,7 @@ def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], Bo 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, diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index dc1219a62..b74238461 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -71,6 +71,14 @@ def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: return False +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_im_message_event(body): return body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is None 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 6c181e4bc..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 @@ -59,6 +59,28 @@ def next(): } ) +# 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): @@ -98,18 +120,14 @@ def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): assert resp.status == 200 assert "set_suggested_prompts" in req.context - # set_title is assistant-thread-only; a top-level DM is not an assistant thread assert "set_title" not in req.context - # say/get_thread_context/save_thread_context remain assistant-only assert "say" not in req.context assert "get_thread_context" not in req.context assert "save_thread_context" not in req.context - # set_status / say_stream are attached whenever a ts is resolvable assert "say_stream" in req.context assert "set_status" in req.context def test_bot_dm_attaches_suggested_prompts(self): - # set_suggested_prompts is intentionally attached for any IM message, including bot-authored DMs. middleware = AttachingConversationKwargs() req = BoltRequest(body=bot_im_message_event_body, mode="socket_mode") req.context["client"] = WebClient(token="xoxb-test") @@ -120,7 +138,6 @@ def test_bot_dm_attaches_suggested_prompts(self): assert "set_suggested_prompts" in req.context def test_file_share_dm_attaches_suggested_prompts(self): - # A file_share DM is in scope for set_suggested_prompts. middleware = AttachingConversationKwargs() req = BoltRequest(body=file_share_im_message_event_body, mode="socket_mode") req.context["client"] = WebClient(token="xoxb-test") @@ -130,6 +147,30 @@ def test_file_share_dm_attaches_suggested_prompts(self): 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 6a209da7e..576cb390d 100644 --- a/tests/slack_bolt/request/test_payload_utils.py +++ b/tests/slack_bolt/request/test_payload_utils.py @@ -6,6 +6,7 @@ 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, @@ -96,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", @@ -363,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/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 00e0da6fb..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 @@ -62,6 +62,28 @@ async def next(): } ) +# 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 @@ -103,21 +125,16 @@ async def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) assert resp.status == 200 - # set_suggested_prompts is available for any DM to the app assert "set_suggested_prompts" in req.context - # set_title is assistant-thread-only; a top-level DM is not an assistant thread assert "set_title" not in req.context - # say/get_thread_context/save_thread_context remain assistant-only assert "say" not in req.context assert "get_thread_context" not in req.context assert "save_thread_context" not in req.context - # set_status / say_stream are attached whenever a ts is resolvable assert "say_stream" in req.context assert "set_status" in req.context @pytest.mark.asyncio async def test_bot_dm_attaches_suggested_prompts(self): - # set_suggested_prompts is intentionally attached for any IM message, including bot-authored DMs. middleware = AsyncAttachingConversationKwargs() req = AsyncBoltRequest(body=bot_im_message_event_body, mode="socket_mode") req.context["client"] = AsyncWebClient(token="xoxb-test") @@ -129,7 +146,6 @@ async def test_bot_dm_attaches_suggested_prompts(self): @pytest.mark.asyncio async def test_file_share_dm_attaches_suggested_prompts(self): - # A file_share DM is in scope for set_suggested_prompts. middleware = AsyncAttachingConversationKwargs() req = AsyncBoltRequest(body=file_share_im_message_event_body, mode="socket_mode") req.context["client"] = AsyncWebClient(token="xoxb-test") @@ -139,6 +155,32 @@ async def test_file_share_dm_attaches_suggested_prompts(self): 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()