diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 7bfefd017..89671a50c 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -20,11 +20,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - name: Run lint verification @@ -37,11 +37,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - name: Install synchronous dependencies @@ -77,11 +77,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} - name: Install synchronous dependencies @@ -144,11 +144,11 @@ jobs: env: BOLT_PYTHON_CODECOV_RUNNING: "1" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python ${{ env.LATEST_SUPPORTED_PY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.LATEST_SUPPORTED_PY }} - name: Install dependencies diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 56493454d..624243bf6 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,13 +18,13 @@ jobs: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.release.tag_name || github.ref }} persist-credentials: false - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" diff --git a/docs/english/concepts/adding-agent-features.md b/docs/english/concepts/adding-agent-features.md index 865a8af4e..f420a219a 100644 --- a/docs/english/concepts/adding-agent-features.md +++ b/docs/english/concepts/adding-agent-features.md @@ -42,7 +42,7 @@ slack install -E local 5. Enable MCP for your app: - Run `slack app settings` to open your app's settings - - Navigate to **Agents & AI Apps** in the left-side navigation + - Navigate to **Agents** in the left-side navigation - Toggle **Model Context Protocol** on 6. Update your `.env` OAuth environment variables: @@ -80,11 +80,10 @@ Agents can be invoked throughout Slack, such as via @mentions in channels, messa import re from logging import Logger -from agents import Runner from slack_bolt import BoltContext, Say, SayStream, SetStatus from slack_sdk import WebClient -from agent import CaseyDeps, casey_agent +from agent import CaseyDeps, run_casey from thread_context import conversation_store from listeners.views.feedback_builder import build_feedback_blocks @@ -201,14 +200,62 @@ def handle_message( - + :::tip[Using the Assistant side panel] -The Assistant side panel requires additional setup. See the [Assistant class guide](/tools/bolt-python/concepts/using-the-assistant-class). +The assistant messaging experience requires additional setup. See the [Assistant class guide](/tools/bolt-python/concepts/using-the-assistant-class). ::: +How you greet a user and set suggested prompts when they open the agent's DM depends on which [messaging experience](/ai/developing-agents) your app uses: -```py +* The **agent messaging experience** (`agent_view`) is the default and the only experience available to apps going forward. Conversations happen in the app's **Messages** tab, so you listen for the [`app_home_opened`](/reference/events/app_home_opened) event and check for the `messages` tab to detect when a user opens the DM, then set suggested prompts at the top of the tab (no `thread_ts` required). Casey uses this approach. +* The **assistant messaging experience** (`assistant_view`) is the legacy experience, with separate **Chat** and **History** tabs. You listen for the [`assistant_thread_started`](/reference/events/assistant_thread_started) event to detect a thread, then set suggested prompts on it. Existing apps can continue to use this but should migrate to the agent messaging experience. + +Refer to the [agent messaging experience changelog entry](/changelog/2026/06/30/agent-messages-tab) for the full list of changes and a migration checklist. + + + + +```python +from logging import Logger + +from slack_bolt import BoltContext +from slack_sdk import WebClient + +SUGGESTED_PROMPTS = [ + {"title": "Reset Password", "message": "I need to reset my password"}, + {"title": "Request Access", "message": "I need access to a system or tool"}, + {"title": "Network Issues", "message": "I'm having network connectivity issues"}, +] + + +def handle_app_home_opened( + client: WebClient, event: dict, context: BoltContext, logger: Logger +): + """Handle app_home_opened events. + + Under agent_view, this event fires for both the Home tab and the Messages + tab (the agent DM). Branch on ``event["tab"]``. + """ + try: + if event.get("tab") == "messages": + # Suggested prompts pin to the top of the Messages tab; no thread_ts required. + client.assistant_threads_setSuggestedPrompts( + channel_id=event["channel"], + title="How can I help you today?", + prompts=SUGGESTED_PROMPTS, + ) + return + + # event["tab"] == "home": publish your App Home Block Kit view here + except Exception as e: + logger.exception(f"Failed to handle app_home_opened: {e}") +``` + + + + +```python from logging import Logger from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts @@ -236,6 +283,9 @@ def handle_assistant_thread_started( + + + --- ## Setting status {#setting-assistant-status} @@ -395,7 +445,7 @@ from logging import Logger from slack_bolt import BoltContext, Say, SayStream, SetStatus from slack_sdk import WebClient -from agent import CaseyDeps, casey_agent, get_model +from agent import CaseyDeps, run_casey from thread_context import conversation_store from listeners.views.feedback_builder import build_feedback_blocks @@ -456,13 +506,9 @@ def handle_app_mentioned( channel_id=channel_id, thread_ts=thread_ts, message_ts=event["ts"], + user_token=context.user_token, ) - result = casey_agent.run_sync( - cleaned_text, - model=get_model(), - deps=deps, - message_history=history, - ) + result = run_casey(cleaned_text, deps, message_history=history) # Stream response in thread with feedback buttons streamer = say_stream() @@ -554,6 +600,7 @@ def handle_app_mentioned( channel_id=channel_id, thread_ts=thread_ts, message_ts=event["ts"], + user_token=context.user_token, ) response_text, new_session_id = run_casey_agent( cleaned_text, session_id=existing_session_id, deps=deps @@ -583,11 +630,10 @@ def handle_app_mentioned( import re from logging import Logger -from agents import Runner from slack_bolt import BoltContext, Say, SayStream, SetStatus from slack_sdk import WebClient -from agent import CaseyDeps, casey_agent +from agent import CaseyDeps, run_casey from thread_context import conversation_store from listeners.views.feedback_builder import build_feedback_blocks @@ -654,8 +700,9 @@ def handle_app_mentioned( channel_id=channel_id, thread_ts=thread_ts, message_ts=event["ts"], + user_token=context.user_token, ) - result = Runner.run_sync(casey_agent, input=input_items, context=deps) + result = run_casey(input_items, deps) # Stream response in thread with feedback buttons streamer = say_stream() diff --git a/docs/english/concepts/using-the-assistant-class.md b/docs/english/concepts/using-the-assistant-class.md index 40c97d0cd..55ca3d110 100644 --- a/docs/english/concepts/using-the-assistant-class.md +++ b/docs/english/concepts/using-the-assistant-class.md @@ -4,7 +4,11 @@ If you don't have a paid workspace for development, you can join the [Developer Program](https://api.slack.com/developer-program) and provision a sandbox with access to all Slack features for free. ::: -The `Assistant` class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the Agents & AI Apps feature enabled. +The `Assistant` class can be used to handle the incoming events expected from a user interacting with an app in Slack that has the **Agents** feature enabled. + +:::warning[The `Assistant` class targets the assistant messaging experience] +The `Assistant` class handles the [assistant messaging experience](/ai/developing-agents) (`assistant_view`), in which agent conversations happen in separate Chat and History tabs. Apps going forward use the agent messaging experience (`agent_view`) by default, where conversations happen in the Messages tab and you handle events such as [`app_home_opened`](/reference/events/app_home_opened) and [`message.im`](/reference/events/message.im) directly. Refer to [Adding agent features](/tools/bolt-python/concepts/adding-agent-features) and the [agent messaging experience changelog entry](/changelog/2026/06/30/agent-messages-tab) for the default experience and migration guidance. +::: A typical flow would look like: @@ -58,7 +62,7 @@ If you do provide your own `threadContextStore` property, it must feature `find` ## Configuring your app to support the `Assistant` class {#configuring-assistant-class} -1. Within [App Settings](https://api.slack.com/apps), enable the **Agents & AI Apps** feature. +1. Within [App Settings](https://api.slack.com/apps), enable the **Agents** feature. 2. Within the App Settings **OAuth & Permissions** page, add the following scopes: * [`assistant:write`](/reference/scopes/assistant.write) @@ -326,4 +330,4 @@ def respond_to_bot_messages(logger: logging.Logger, set_status: SetStatus, say: See the [_Creating agents: adding and handling feedback_](/tools/bolt-python/concepts/adding-agent-features#adding-and-handling-feedback) section for adding feedback buttons with Block Kit. -Want to see the functionality described throughout this guide in action? We've created a [App Agent Template](https://github.com/slack-samples/bolt-python-assistant-template) repo for you to build from. \ No newline at end of file +Want to see the functionality described throughout this guide in action? We've created a [Starter Agent Template](https://github.com/slack-samples/bolt-python-starter-agent) repo for you to build from. \ No newline at end of file diff --git a/docs/reference/async_app.html b/docs/reference/async_app.html index 6a45d2ada..670ba58d0 100644 --- a/docs/reference/async_app.html +++ b/docs/reference/async_app.html @@ -5415,7 +5415,7 @@

Class variables

class AsyncSetSuggestedPrompts -(client: slack_sdk.web.async_client.AsyncWebClient,
channel_id: str,
thread_ts: str)
+(client: slack_sdk.web.async_client.AsyncWebClient,
channel_id: str,
thread_ts: str | None = None)
@@ -5425,13 +5425,13 @@

Class variables

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
@@ -5441,6 +5441,7 @@ 

Class variables

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: @@ -5451,7 +5452,7 @@

Class variables

return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
@@ -5467,7 +5468,7 @@

Class variables

The type of the None singleton.

-
var thread_ts : str
+
var thread_ts : str | None

The type of the None singleton.

diff --git a/docs/reference/authorization/authorize_result.html b/docs/reference/authorization/authorize_result.html index 6eac3724d..d53c5cd5c 100644 --- a/docs/reference/authorization/authorize_result.html +++ b/docs/reference/authorization/authorize_result.html @@ -48,7 +48,7 @@

Classes

class AuthorizeResult -(*,
enterprise_id: str | None,
team_id: str | None,
team: str | None = None,
url: str | None = None,
bot_user_id: str | None = None,
bot_id: str | None = None,
bot_token: str | None = None,
bot_scopes: str | Sequence[str] | None = None,
user_id: str | None = None,
user: str | None = None,
user_token: str | None = None,
user_scopes: str | Sequence[str] | None = None)
+(*,
enterprise_id: str | None,
team_id: str | None,
team: str | None = None,
url: str | None = None,
bot_user_id: str | None = None,
bot_id: str | None = None,
bot_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_id: str | None = None,
user: str | None = None,
user_token: str | None = None,
user_scopes: Sequence[str] | str | None = None)
@@ -246,7 +246,7 @@

Class variables

Static methods

-def from_auth_test_response(*,
bot_token: str | None = None,
user_token: str | None = None,
bot_scopes: str | Sequence[str] | None = None,
user_scopes: str | Sequence[str] | None = None,
auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse'),
user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse') | None = None)
+def from_auth_test_response(*,
bot_token: str | None = None,
user_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_scopes: Sequence[str] | str | None = None,
auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
diff --git a/docs/reference/authorization/index.html b/docs/reference/authorization/index.html index 19de311df..2fdd1f916 100644 --- a/docs/reference/authorization/index.html +++ b/docs/reference/authorization/index.html @@ -75,7 +75,7 @@

Classes

class AuthorizeResult -(*,
enterprise_id: str | None,
team_id: str | None,
team: str | None = None,
url: str | None = None,
bot_user_id: str | None = None,
bot_id: str | None = None,
bot_token: str | None = None,
bot_scopes: str | Sequence[str] | None = None,
user_id: str | None = None,
user: str | None = None,
user_token: str | None = None,
user_scopes: str | Sequence[str] | None = None)
+(*,
enterprise_id: str | None,
team_id: str | None,
team: str | None = None,
url: str | None = None,
bot_user_id: str | None = None,
bot_id: str | None = None,
bot_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_id: str | None = None,
user: str | None = None,
user_token: str | None = None,
user_scopes: Sequence[str] | str | None = None)
@@ -273,7 +273,7 @@

Class variables

Static methods

-def from_auth_test_response(*,
bot_token: str | None = None,
user_token: str | None = None,
bot_scopes: str | Sequence[str] | None = None,
user_scopes: str | Sequence[str] | None = None,
auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse'),
user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | ForwardRef('AsyncSlackResponse') | None = None)
+def from_auth_test_response(*,
bot_token: str | None = None,
user_token: str | None = None,
bot_scopes: Sequence[str] | str | None = None,
user_scopes: Sequence[str] | str | None = None,
auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse,
user_auth_test_response: slack_sdk.web.slack_response.SlackResponse | AsyncSlackResponse | None = None)
diff --git a/docs/reference/context/assistant/assistant_utilities.html b/docs/reference/context/assistant/assistant_utilities.html index 40db52284..2200c4f10 100644 --- a/docs/reference/context/assistant/assistant_utilities.html +++ b/docs/reference/context/assistant/assistant_utilities.html @@ -86,28 +86,10 @@

Classes

# When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> SetStatus: - warnings.warn( - "AssistantUtilities.set_status is deprecated. " - "Use the set_status argument directly in your listener function " - "or access it via context.set_status instead.", - DeprecationWarning, - stacklevel=2, - ) - return SetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> SetTitle: return SetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> SetSuggestedPrompts: - return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> Say: def build_metadata() -> Optional[dict]: @@ -204,37 +186,6 @@

Instance variables

-
prop set_statusSetStatus
-
-
- -Expand source code - -
@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)
-
-
-
-
prop set_suggested_promptsSetSuggestedPrompts
-
-
- -Expand source code - -
@property
-def set_suggested_prompts(self) -> SetSuggestedPrompts:
-    return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
-
-
-
prop set_titleSetTitle
@@ -248,22 +199,6 @@

Instance variables

-

Methods

-
-
-def is_valid(self) ‑> bool -
-
-
- -Expand source code - -
def is_valid(self) -> bool:
-    return self.channel_id is not None and self.thread_ts is not None
-
-
-
-
@@ -286,12 +221,9 @@

channel_id
  • client
  • get_thread_context
  • -
  • is_valid
  • payload
  • save_thread_context
  • say
  • -
  • set_status
  • -
  • set_suggested_prompts
  • set_title
  • thread_context_store
  • thread_ts
  • diff --git a/docs/reference/context/assistant/async_assistant_utilities.html b/docs/reference/context/assistant/async_assistant_utilities.html index fc77b80cb..70f4d0d23 100644 --- a/docs/reference/context/assistant/async_assistant_utilities.html +++ b/docs/reference/context/assistant/async_assistant_utilities.html @@ -86,28 +86,10 @@

    Classes

    # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> AsyncSetStatus: - warnings.warn( - "AsyncAssistantUtilities.set_status is deprecated. " - "Use the set_status argument directly in your listener function " - "or access it via context.set_status instead.", - DeprecationWarning, - stacklevel=2, - ) - return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> AsyncSetTitle: return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts: - return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> AsyncSay: return AsyncSay( @@ -198,37 +180,6 @@

    Instance variables

    -
    prop set_statusAsyncSetStatus
    -
    -
    - -Expand source code - -
    @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)
    -
    -
    -
    -
    prop set_suggested_promptsAsyncSetSuggestedPrompts
    -
    -
    - -Expand source code - -
    @property
    -def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts:
    -    return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts)
    -
    -
    -
    prop set_titleAsyncSetTitle
    @@ -242,22 +193,6 @@

    Instance variables

    -

    Methods

    -
    -
    -def is_valid(self) ‑> bool -
    -
    -
    - -Expand source code - -
    def is_valid(self) -> bool:
    -    return self.channel_id is not None and self.thread_ts is not None
    -
    -
    -
    -
    @@ -280,12 +215,9 @@

    channel_id
  • client
  • get_thread_context
  • -
  • is_valid
  • payload
  • save_thread_context
  • say
  • -
  • set_status
  • -
  • set_suggested_prompts
  • set_title
  • thread_context_store
  • thread_ts
  • diff --git a/docs/reference/context/assistant/thread_context_store/file/index.html b/docs/reference/context/assistant/thread_context_store/file/index.html index cbb4e4db6..4a5d944e1 100644 --- a/docs/reference/context/assistant/thread_context_store/file/index.html +++ b/docs/reference/context/assistant/thread_context_store/file/index.html @@ -48,7 +48,7 @@

    Classes

    class FileAssistantThreadContextStore -(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts')
    diff --git a/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html index 4feda52ba..1c7656456 100644 --- a/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/async_set_suggested_prompts.html @@ -48,7 +48,7 @@

    Classes

    class AsyncSetSuggestedPrompts -(client: slack_sdk.web.async_client.AsyncWebClient,
    channel_id: str,
    thread_ts: str)
    +(client: slack_sdk.web.async_client.AsyncWebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -58,13 +58,13 @@

    Classes

    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
    @@ -74,6 +74,7 @@ 

    Classes

    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: @@ -84,7 +85,7 @@

    Classes

    return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -100,7 +101,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    diff --git a/docs/reference/context/set_suggested_prompts/index.html b/docs/reference/context/set_suggested_prompts/index.html index 12d864dde..cf606ae2f 100644 --- a/docs/reference/context/set_suggested_prompts/index.html +++ b/docs/reference/context/set_suggested_prompts/index.html @@ -59,7 +59,7 @@

    Classes

    class SetSuggestedPrompts -(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +(client: slack_sdk.web.client.WebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -69,13 +69,13 @@

    Classes

    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
    @@ -85,6 +85,7 @@ 

    Classes

    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: @@ -95,7 +96,7 @@

    Classes

    return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -111,7 +112,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    diff --git a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html index 6c0385e57..f034fc677 100644 --- a/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html +++ b/docs/reference/context/set_suggested_prompts/set_suggested_prompts.html @@ -48,7 +48,7 @@

    Classes

    class SetSuggestedPrompts -(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +(client: slack_sdk.web.client.WebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -58,13 +58,13 @@

    Classes

    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
    @@ -74,6 +74,7 @@ 

    Classes

    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: @@ -84,7 +85,7 @@

    Classes

    return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -100,7 +101,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    diff --git a/docs/reference/error/index.html b/docs/reference/error/index.html index f57d690e9..9a9998e63 100644 --- a/docs/reference/error/index.html +++ b/docs/reference/error/index.html @@ -72,7 +72,7 @@

    Subclasses

    class BoltUnhandledRequestError -(*,
    request: ForwardRef('BoltRequest') | ForwardRef('AsyncBoltRequest'),
    current_response: ForwardRef('BoltResponse') | None,
    last_global_middleware_name: str | None = None)
    +(*,
    request: BoltRequest | AsyncBoltRequest,
    current_response: BoltResponse | None,
    last_global_middleware_name: str | None = None)
    diff --git a/docs/reference/index.html b/docs/reference/index.html index 70d84875a..ac1666851 100644 --- a/docs/reference/index.html +++ b/docs/reference/index.html @@ -5337,7 +5337,7 @@

    Returns

    class FileAssistantThreadContextStore -(base_dir: str = '/Users/eden.zimbelman/.bolt-app-assistant-thread-contexts') +(base_dir: str = '/Users/wbergamin/.bolt-app-assistant-thread-contexts')
    @@ -6039,7 +6039,7 @@

    Class variables

    class SetSuggestedPrompts -(client: slack_sdk.web.client.WebClient, channel_id: str, thread_ts: str) +(client: slack_sdk.web.client.WebClient,
    channel_id: str,
    thread_ts: str | None = None)
    @@ -6049,13 +6049,13 @@

    Class variables

    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
    @@ -6065,6 +6065,7 @@ 

    Class variables

    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: @@ -6075,7 +6076,7 @@

    Class variables

    return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, )
    @@ -6091,7 +6092,7 @@

    Class variables

    The type of the None singleton.

    -
    var thread_ts : str
    +
    var thread_ts : str | None

    The type of the None singleton.

    diff --git a/docs/reference/logger/messages.html b/docs/reference/logger/messages.html index 1072e6479..e69b45fc9 100644 --- a/docs/reference/logger/messages.html +++ b/docs/reference/logger/messages.html @@ -409,7 +409,7 @@

    Functions

    -def warning_unhandled_by_global_middleware(name: str,
    req: BoltRequest | ForwardRef('AsyncBoltRequest')) ‑> str
    +def warning_unhandled_by_global_middleware(name: str,
    req: BoltRequest | AsyncBoltRequest) ‑> str
    @@ -427,7 +427,7 @@

    Functions

    -def warning_unhandled_request(req: BoltRequest | ForwardRef('AsyncBoltRequest')) ‑> str +def warning_unhandled_request(req: BoltRequest | AsyncBoltRequest) ‑> str
    diff --git a/docs/reference/middleware/async_builtins.html b/docs/reference/middleware/async_builtins.html index 1ddea9222..8f7b1ba4f 100644 --- a/docs/reference/middleware/async_builtins.html +++ b/docs/reference/middleware/async_builtins.html @@ -70,34 +70,50 @@

    Classes

    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 is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AsyncAssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) - # TODO: in the future we might want to introduce a "proper" extract_ts utility - thread_ts = 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, - ) + # 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()

    A middleware can process request data before other middleware and listener functions.

    diff --git a/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html b/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html index a0f5bdf85..e2bbe7045 100644 --- a/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html +++ b/docs/reference/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.html @@ -70,34 +70,50 @@

    Classes

    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() - # 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 is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AsyncAssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = AsyncSetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = AsyncSayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return await next()

    A middleware can process request data before other middleware and listener functions.

    diff --git a/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html b/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html index 8a1911323..e9d558fec 100644 --- a/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html +++ b/docs/reference/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.html @@ -64,34 +64,50 @@

    Classes

    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() - # 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 is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = SayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return next()

    A middleware can process request data before other middleware and listener functions.

    diff --git a/docs/reference/middleware/attaching_conversation_kwargs/index.html b/docs/reference/middleware/attaching_conversation_kwargs/index.html index 308a52712..38da4442e 100644 --- a/docs/reference/middleware/attaching_conversation_kwargs/index.html +++ b/docs/reference/middleware/attaching_conversation_kwargs/index.html @@ -75,34 +75,50 @@

    Classes

    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() - # 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 is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = SayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return next()

    A middleware can process request data before other middleware and listener functions.

    diff --git a/docs/reference/middleware/index.html b/docs/reference/middleware/index.html index 9f2053a5d..153342bc1 100644 --- a/docs/reference/middleware/index.html +++ b/docs/reference/middleware/index.html @@ -136,34 +136,50 @@

    Classes

    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() - # 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 is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = SayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return next()

    A middleware can process request data before other middleware and listener functions.

    diff --git a/docs/reference/oauth/async_oauth_settings.html b/docs/reference/oauth/async_oauth_settings.html index 5e6a543c4..3b8c04edb 100644 --- a/docs/reference/oauth/async_oauth_settings.html +++ b/docs/reference/oauth/async_oauth_settings.html @@ -48,7 +48,7 @@

    Classes

    class AsyncOAuthSettings -(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: str | Sequence[str] | None = None,
    user_scopes: str | Sequence[str] | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: AsyncCallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
    +(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: Sequence[str] | str | None = None,
    user_scopes: Sequence[str] | str | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: AsyncCallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.async_installation_store.AsyncInstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.async_state_store.AsyncOAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.async_oauth_settings (WARNING)>)
    diff --git a/docs/reference/oauth/oauth_settings.html b/docs/reference/oauth/oauth_settings.html index 1eb2ab7dd..cd8def497 100644 --- a/docs/reference/oauth/oauth_settings.html +++ b/docs/reference/oauth/oauth_settings.html @@ -48,7 +48,7 @@

    Classes

    class OAuthSettings -(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: str | Sequence[str] | None = None,
    user_scopes: str | Sequence[str] | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: CallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.state_store.OAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
    +(*,
    client_id: str | None = None,
    client_secret: str | None = None,
    scopes: Sequence[str] | str | None = None,
    user_scopes: Sequence[str] | str | None = None,
    redirect_uri: str | None = None,
    install_path: str = '/slack/install',
    install_page_rendering_enabled: bool = True,
    redirect_uri_path: str = '/slack/oauth_redirect',
    callback_options: CallbackOptions | None = None,
    success_url: str | None = None,
    failure_url: str | None = None,
    authorization_url: str | None = None,
    installation_store: slack_sdk.oauth.installation_store.installation_store.InstallationStore | None = None,
    installation_store_bot_only: bool = False,
    token_rotation_expiration_minutes: int = 120,
    user_token_resolution: str = 'authed_user',
    state_validation_enabled: bool = True,
    state_store: slack_sdk.oauth.state_store.state_store.OAuthStateStore | None = None,
    state_cookie_name: str = 'slack-app-oauth-state',
    state_expiration_seconds: int = 600,
    logger: logging.Logger = <Logger slack_bolt.oauth.oauth_settings (WARNING)>)
    diff --git a/docs/reference/request/payload_utils.html b/docs/reference/request/payload_utils.html index 4fe75fd81..b583c3a51 100644 --- a/docs/reference/request/payload_utils.html +++ b/docs/reference/request/payload_utils.html @@ -63,6 +63,39 @@

    Functions

    +
    +def is_any_im_message_event(body: Dict[str, Any]) ‑> bool +
    +
    +
    + +Expand source code + +
    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_app_home_opened_event(body: Dict[str, Any], tab: str | None = None) ‑> bool +
    +
    +
    + +Expand source code + +
    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_assistant_event(body: Dict[str, Any]) ‑> bool
    @@ -159,10 +192,9 @@

    Functions

    Expand source code
    def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
    -    if is_event(body):
    +    if is_any_im_message_event(body):
             return (
    -            is_message_event_in_assistant_thread(body)
    -            and body["event"].get("subtype") is None
    +            body["event"].get("subtype") is None
                 and body["event"].get("thread_ts") is not None
                 and body["event"].get("bot_id") is not None
             )
    @@ -248,17 +280,32 @@ 

    Functions

    -
    -def is_message_event_in_assistant_thread(body: Dict[str, Any]) ‑> bool +
    +def is_im_message_event(body: Dict[str, Any]) ‑> bool +
    +
    +
    + +Expand source code + +
    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_message_event(body: Dict[str, Any]) ‑> bool
    Expand source code -
    def is_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
    +
    def is_message_event(body: Dict[str, Any]) -> bool:
         if is_event(body):
    -        return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im"
    +        return body["event"]["type"] == "message"
         return False
    @@ -299,14 +346,10 @@

    Functions

    def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
         # message_changed, message_deleted etc.
    -    if is_event(body):
    -        return (
    -            is_message_event_in_assistant_thread(body)
    -            and not is_user_message_event_in_assistant_thread(body)
    -            and (
    -                _is_other_message_sub_event(body["event"].get("message"))
    -                or _is_other_message_sub_event(body["event"].get("previous_message"))
    -            )
    +    if is_any_im_message_event(body):
    +        return not is_user_message_event_in_assistant_thread(body) and (
    +            _is_other_message_sub_event(body["event"].get("message"))
    +            or _is_other_message_sub_event(body["event"].get("previous_message"))
             )
         return False
    @@ -347,13 +390,8 @@

    Functions

    Expand source code
    def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool:
    -    if is_event(body):
    -        return (
    -            is_message_event_in_assistant_thread(body)
    -            and body["event"].get("subtype") in (None, "file_share")
    -            and body["event"].get("thread_ts") is not None
    -            and body["event"].get("bot_id") is None
    -        )
    +    if is_im_message_event(body):
    +        return body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is None
         return False
    @@ -491,7 +529,7 @@

    Functions

    Expand source code
    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
    @@ -582,6 +620,8 @@

    Functions

  • Functions

    • is_action
    • +
    • is_any_im_message_event
    • +
    • is_app_home_opened_event
    • is_assistant_event
    • is_assistant_thread_context_changed_event
    • is_assistant_thread_started_event
    • @@ -595,7 +635,8 @@

      Functions

    • is_event
    • is_function
    • is_global_shortcut
    • -
    • is_message_event_in_assistant_thread
    • +
    • is_im_message_event
    • +
    • is_message_event
    • is_message_shortcut
    • is_options
    • is_other_message_sub_event_in_assistant_thread
    • diff --git a/requirements/adapter_dev.txt b/requirements/adapter_dev.txt index 14dfa9c84..d118106b9 100644 --- a/requirements/adapter_dev.txt +++ b/requirements/adapter_dev.txt @@ -6,7 +6,7 @@ bottle>=0.12,<1 chalice>=1.28,<1.31; python_version<"3.9" chalice>=1.32.0,<2; python_version>="3.9" cheroot<12 -CherryPy>=18,<19 +CherryPy>=18.10.0,<19 Django>=3.2,<4; python_version<"3.8" Django>=4.2.30,<6; python_version>="3.8" falcon>=2,<4; python_version<"3.9" diff --git a/requirements/async_dev.txt b/requirements/async_dev.txt index 606d10fef..a40433441 100644 --- a/requirements/async_dev.txt +++ b/requirements/async_dev.txt @@ -1,4 +1,4 @@ # pip install -r requirements/async_dev.txt aiohttp>=3,<4; python_version<"3.9" aiohttp>=3.13.5,<4; python_version>="3.9" -websockets<16 +websockets<17 diff --git a/requirements/test.txt b/requirements/test.txt index e007e6637..020e2f41a 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,3 +1,3 @@ # pip install -r requirements/test.txt -pytest<8.5 +pytest<9.2 pytest-cov>=7.1.0,<8; python_version>="3.14" # only needed to evaluate coverage on the latest supported python version diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py index 42f05c94b..e9614fd8d 100644 --- a/slack_bolt/context/assistant/assistant_utilities.py +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -1,4 +1,3 @@ -import warnings from typing import Optional from slack_sdk.web import WebClient @@ -11,8 +10,6 @@ from .internals import has_channel_id_and_thread_ts from ..get_thread_context.get_thread_context import GetThreadContext from ..save_thread_context import SaveThreadContext -from ..set_status import SetStatus -from ..set_suggested_prompts import SetSuggestedPrompts from ..set_title import SetTitle @@ -47,28 +44,10 @@ def __init__( # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> SetStatus: - warnings.warn( - "AssistantUtilities.set_status is deprecated. " - "Use the set_status argument directly in your listener function " - "or access it via context.set_status instead.", - DeprecationWarning, - stacklevel=2, - ) - return SetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> SetTitle: return SetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> SetSuggestedPrompts: - return SetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> Say: def build_metadata() -> Optional[dict]: diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py index b40b2619c..6acf531ce 100644 --- a/slack_bolt/context/assistant/async_assistant_utilities.py +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -1,4 +1,3 @@ -import warnings from typing import Optional from slack_sdk.web.async_client import AsyncWebClient @@ -14,8 +13,6 @@ from .internals import has_channel_id_and_thread_ts from ..get_thread_context.async_get_thread_context import AsyncGetThreadContext from ..save_thread_context.async_save_thread_context import AsyncSaveThreadContext -from ..set_status.async_set_status import AsyncSetStatus -from ..set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from ..set_title.async_set_title import AsyncSetTitle @@ -50,28 +47,10 @@ def __init__( # When moving this code to Bolt internals, no need to raise an exception for this pattern raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") - def is_valid(self) -> bool: - return self.channel_id is not None and self.thread_ts is not None - - @property - def set_status(self) -> AsyncSetStatus: - warnings.warn( - "AsyncAssistantUtilities.set_status is deprecated. " - "Use the set_status argument directly in your listener function " - "or access it via context.set_status instead.", - DeprecationWarning, - stacklevel=2, - ) - return AsyncSetStatus(self.client, self.channel_id, self.thread_ts) - @property def set_title(self) -> AsyncSetTitle: return AsyncSetTitle(self.client, self.channel_id, self.thread_ts) - @property - def set_suggested_prompts(self) -> AsyncSetSuggestedPrompts: - return AsyncSetSuggestedPrompts(self.client, self.channel_id, self.thread_ts) - @property def say(self) -> AsyncSay: return AsyncSay( diff --git a/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py index 2079b6448..68b41858b 100644 --- a/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py @@ -7,13 +7,13 @@ class AsyncSetSuggestedPrompts: client: AsyncWebClient channel_id: str - thread_ts: str + thread_ts: Optional[str] def __init__( self, client: AsyncWebClient, channel_id: str, - thread_ts: str, + thread_ts: Optional[str] = None, ): self.client = client self.channel_id = channel_id @@ -23,6 +23,7 @@ async def __call__( self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> AsyncSlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -33,7 +34,7 @@ async def __call__( return await self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, ) diff --git a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py index 21ff815e1..349481df4 100644 --- a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -7,13 +7,13 @@ class SetSuggestedPrompts: client: WebClient channel_id: str - thread_ts: str + thread_ts: Optional[str] def __init__( self, client: WebClient, channel_id: str, - thread_ts: str, + thread_ts: Optional[str] = None, ): self.client = client self.channel_id = channel_id @@ -23,6 +23,7 @@ def __call__( self, prompts: Sequence[Union[str, Dict[str, str]]], title: Optional[str] = None, + thread_ts: Optional[str] = None, ) -> SlackResponse: prompts_arg: List[Dict[str, str]] = [] for prompt in prompts: @@ -33,7 +34,7 @@ def __call__( return self.client.assistant_threads_setSuggestedPrompts( channel_id=self.channel_id, - thread_ts=self.thread_ts, + thread_ts=thread_ts if thread_ts is not None else self.thread_ts, prompts=prompts_arg, title=title, ) diff --git a/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py index 315ec2a50..ab69f5768 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/async_attaching_conversation_kwargs.py @@ -4,9 +4,17 @@ from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore from slack_bolt.context.say_stream.async_say_stream import AsyncSayStream from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts from slack_bolt.middleware.async_middleware import AsyncMiddleware from slack_bolt.request.async_request import AsyncBoltRequest -from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.request.payload_utils import ( + is_app_home_opened_event, + is_assistant_event, + is_assistant_thread_context_changed_event, + is_assistant_thread_started_event, + is_im_message_event, + to_event, +) from slack_bolt.response import BoltResponse @@ -25,32 +33,48 @@ async def async_process( next: Callable[[], Awaitable[BoltResponse]], ) -> Optional[BoltResponse]: event = to_event(req.body) - if event is not None: - if is_assistant_event(req.body): - assistant = AsyncAssistantUtilities( - payload=event, - context=req.context, - thread_context_store=self.thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - - # TODO: in the future we might want to introduce a "proper" extract_ts utility - thread_ts = req.context.thread_ts or event.get("ts") - if req.context.channel_id and thread_ts: - req.context["set_status"] = AsyncSetStatus( - client=req.context.client, - channel_id=req.context.channel_id, - thread_ts=thread_ts, - ) - req.context["say_stream"] = AsyncSayStream( - client=req.context.client, - channel=req.context.channel_id, - recipient_team_id=req.context.team_id or req.context.enterprise_id, - recipient_user_id=req.context.user_id, - thread_ts=thread_ts, - ) + if event is None: + return await next() + if req.context.channel_id is None: + return await next() + + if is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AsyncAssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = AsyncSetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = AsyncSetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = AsyncSayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return await next() diff --git a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py index 33847fd56..2d6ce7b01 100644 --- a/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py +++ b/slack_bolt/middleware/attaching_conversation_kwargs/attaching_conversation_kwargs.py @@ -1,11 +1,19 @@ from typing import Optional, Callable -from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore from slack_bolt.context.say_stream.say_stream import SayStream from slack_bolt.context.set_status.set_status import SetStatus +from slack_bolt.context.set_suggested_prompts.set_suggested_prompts import SetSuggestedPrompts from slack_bolt.middleware import Middleware -from slack_bolt.request.payload_utils import is_assistant_event, to_event +from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities +from slack_bolt.request.payload_utils import ( + is_app_home_opened_event, + is_assistant_event, + is_assistant_thread_context_changed_event, + is_assistant_thread_started_event, + is_im_message_event, + to_event, +) from slack_bolt.request.request import BoltRequest from slack_bolt.response.response import BoltResponse @@ -19,32 +27,48 @@ def __init__(self, thread_context_store: Optional[AssistantThreadContextStore] = def process(self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse]) -> Optional[BoltResponse]: event = to_event(req.body) - if event is not None: - if is_assistant_event(req.body): - assistant = AssistantUtilities( - payload=event, - context=req.context, - thread_context_store=self.thread_context_store, - ) - req.context["say"] = assistant.say - req.context["set_title"] = assistant.set_title - req.context["set_suggested_prompts"] = assistant.set_suggested_prompts - req.context["get_thread_context"] = assistant.get_thread_context - req.context["save_thread_context"] = assistant.save_thread_context - - # TODO: in the future we might want to introduce a "proper" extract_ts utility - thread_ts = req.context.thread_ts or event.get("ts") - if req.context.channel_id and thread_ts: - req.context["set_status"] = SetStatus( - client=req.context.client, - channel_id=req.context.channel_id, - thread_ts=thread_ts, - ) - req.context["say_stream"] = SayStream( - client=req.context.client, - channel=req.context.channel_id, - recipient_team_id=req.context.team_id or req.context.enterprise_id, - recipient_user_id=req.context.user_id, - thread_ts=thread_ts, - ) + if event is None: + return next() + if req.context.channel_id is None: + return next() + + if is_assistant_event(req.body): + # TODO: eventually we might remove this assistant specific logic + assistant = AssistantUtilities( + payload=event, + context=req.context, + thread_context_store=self.thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_title"] = assistant.set_title + req.context["get_thread_context"] = assistant.get_thread_context + req.context["save_thread_context"] = assistant.save_thread_context + + if ( + is_im_message_event(req.body) + or is_assistant_thread_started_event(req.body) + or is_assistant_thread_context_changed_event(req.body) + or is_app_home_opened_event(req.body, tab="messages") + ): + req.context["set_suggested_prompts"] = SetSuggestedPrompts( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=req.context.thread_ts, + ) + + # TODO: in the future we might want to introduce a "proper" extract_ts utility + thread_ts_or_ts = req.context.thread_ts or event.get("ts") + if thread_ts_or_ts: + req.context["set_status"] = SetStatus( + client=req.context.client, + channel_id=req.context.channel_id, + thread_ts=thread_ts_or_ts, + ) + req.context["say_stream"] = SayStream( + client=req.context.client, + channel=req.context.channel_id, + recipient_team_id=req.context.team_id or req.context.enterprise_id, + recipient_user_id=req.context.user_id, + thread_ts=thread_ts_or_ts, + ) return next() diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 1ebf70d4f..b74238461 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -14,7 +14,7 @@ def to_event(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: def to_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if is_event(body) and body["event"]["type"] == "message": + if is_message_event(body): return to_event(body) return None @@ -31,6 +31,25 @@ def is_workflow_step_execute(body: Dict[str, Any]) -> bool: return is_event(body) and body["event"]["type"] == "workflow_step_execute" and "workflow_step" in body["event"] +def is_message_event(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "message" + return False + + +def is_any_im_message_event(body: Dict[str, Any]) -> bool: + if is_message_event(body): + # Any message event with no subtype or any subtype (message_changed, message_deleted, etc.) + return body["event"].get("channel_type") == "im" + return False + + +def is_im_message_event(body: Dict[str, Any]) -> bool: + if is_any_im_message_event(body): + return body["event"].get("subtype") in (None, "file_share") + return False + + def is_assistant_event(body: Dict[str, Any]) -> bool: return is_event(body) and ( is_assistant_thread_started_event(body) @@ -52,28 +71,24 @@ def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: return False -def is_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): - return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im" +def is_app_home_opened_event(body: Dict[str, Any], tab: Optional[str] = None) -> bool: + if is_event(body) and body["event"]["type"] == "app_home_opened": + if tab is not None: + return body["event"].get("tab") == tab + return True return False def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): - return ( - is_message_event_in_assistant_thread(body) - and body["event"].get("subtype") in (None, "file_share") - and body["event"].get("thread_ts") is not None - and body["event"].get("bot_id") is None - ) + if is_im_message_event(body): + return body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is None return False def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: - if is_event(body): + if is_any_im_message_event(body): return ( - is_message_event_in_assistant_thread(body) - and body["event"].get("subtype") is None + body["event"].get("subtype") is None and body["event"].get("thread_ts") is not None and body["event"].get("bot_id") is not None ) @@ -82,14 +97,10 @@ def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: def is_other_message_sub_event_in_assistant_thread(body: Dict[str, Any]) -> bool: # message_changed, message_deleted etc. - if is_event(body): - return ( - is_message_event_in_assistant_thread(body) - and not is_user_message_event_in_assistant_thread(body) - and ( - _is_other_message_sub_event(body["event"].get("message")) - or _is_other_message_sub_event(body["event"].get("previous_message")) - ) + if is_any_im_message_event(body): + return not is_user_message_event_in_assistant_thread(body) and ( + _is_other_message_sub_event(body["event"].get("message")) + or _is_other_message_sub_event(body["event"].get("previous_message")) ) return False diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 79018b9b2..2c08c0adb 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1,3 +1,3 @@ """Check the latest version at https://pypi.org/project/slack-bolt/""" -__version__ = "1.29.0" +__version__ = "1.30.0" diff --git a/tests/scenario_tests/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py index a1c3f1343..c95296154 100644 --- a/tests/scenario_tests/test_events_assistant.py +++ b/tests/scenario_tests/test_events_assistant.py @@ -409,7 +409,7 @@ def build_payload(event: dict) -> dict: "user_profile": {}, "thread_ts": "1726133698.626339", "parent_user_id": "W222", - "channel": "D111", + "channel": "C111", "event_ts": "1726133700.887259", "channel_type": "channel", } @@ -444,7 +444,7 @@ def build_payload(event: dict) -> dict: "reply_users": ["U222", "W111"], "is_locked": False, }, - "channel": "D111", + "channel": "C111", "hidden": True, "ts": "1726133701.028300", "event_ts": "1726133701.028300", diff --git a/tests/scenario_tests/test_events_ignore_self.py b/tests/scenario_tests/test_events_ignore_self.py index db7d07ea8..3c66f3278 100644 --- a/tests/scenario_tests/test_events_ignore_self.py +++ b/tests/scenario_tests/test_events_ignore_self.py @@ -100,6 +100,7 @@ def handle_app_mention(say): "type": "message", "channel": "C111", "ts": "1599529504.000400", + "channel_type": "channel", }, "reaction": "heart_eyes", "item_user": "W111", diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py index edc77ecf3..9e1176c74 100644 --- a/tests/scenario_tests_async/test_events_assistant.py +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -483,7 +483,7 @@ def build_payload(event: dict) -> dict: "user_profile": {}, "thread_ts": "1726133698.626339", "parent_user_id": "W222", - "channel": "D111", + "channel": "C111", "event_ts": "1726133700.887259", "channel_type": "channel", } @@ -518,7 +518,7 @@ def build_payload(event: dict) -> dict: "reply_users": ["U222", "W111"], "is_locked": False, }, - "channel": "D111", + "channel": "C111", "hidden": True, "ts": "1726133701.028300", "event_ts": "1726133701.028300", diff --git a/tests/scenario_tests_async/test_events_ignore_self.py b/tests/scenario_tests_async/test_events_ignore_self.py index 7ec9d0cce..fa398a907 100644 --- a/tests/scenario_tests_async/test_events_ignore_self.py +++ b/tests/scenario_tests_async/test_events_ignore_self.py @@ -89,6 +89,7 @@ async def test_self_events_disabled(self): "type": "message", "channel": "C111", "ts": "1599529504.000400", + "channel_type": "channel", }, "reaction": "heart_eyes", "item_user": "W111", diff --git a/tests/slack_bolt/context/test_set_suggested_prompts.py b/tests/slack_bolt/context/test_set_suggested_prompts.py index 792b974b5..a743d5656 100644 --- a/tests/slack_bolt/context/test_set_suggested_prompts.py +++ b/tests/slack_bolt/context/test_set_suggested_prompts.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock, patch + import pytest from slack_sdk import WebClient from slack_sdk.web import SlackResponse @@ -31,6 +33,47 @@ def test_set_suggested_prompts_objects(self): ) assert response.status_code == 200 + def test_set_suggested_prompts_without_thread_ts(self): + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"]) + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts=None, + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + def test_set_suggested_prompts_thread_ts_override(self): + # The call-time thread_ts must win over the stored one + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="999.999") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="123.123", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + def test_set_suggested_prompts_thread_ts_override_falsy(self): + # An explicitly passed falsy thread_ts must be forwarded, not swallowed by the stored value + set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, return_value=MagicMock() + ) as mock_api: + set_suggested_prompts(prompts=["One", "Two"], thread_ts="") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + def test_set_suggested_prompts_invalid(self): set_suggested_prompts = SetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") with pytest.raises(TypeError): diff --git a/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py index b7785eb50..3f46a6b67 100644 --- a/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py +++ b/tests/slack_bolt/middleware/attaching_conversation_kwargs/test_attaching_conversation_kwargs.py @@ -4,6 +4,7 @@ from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from tests.scenario_tests.test_events_assistant import ( + build_payload, thread_started_event_body, user_message_event_body, channel_user_message_event_body, @@ -16,6 +17,70 @@ def next(): ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") +# A top-level DM (not in a thread) is not an assistant thread, but set_suggested_prompts is still attached. +top_level_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "A top-level DM, not in a thread", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A bot-authored top-level DM is also in scope: set_suggested_prompts is attached for any IM message. +bot_im_message_event_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "A DM authored by a bot", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A file_share DM is in scope too (subtype "file_share" passes is_im_message_event). +file_share_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# Opening the Messages tab of App Home is in scope for set_suggested_prompts. +app_home_opened_messages_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +# Opening the Home tab is NOT in scope: set_suggested_prompts should not be attached. +app_home_opened_home_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + class TestAttachingConversationKwargs: def test_assistant_event_attaches_kwargs(self): @@ -46,6 +111,66 @@ def test_user_message_event_attaches_kwargs(self): assert "say_stream" in req.context assert "set_status" in req.context + def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=top_level_im_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "set_title" not in req.context + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + assert "say_stream" in req.context + assert "set_status" in req.context + + def test_bot_dm_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=bot_im_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + def test_file_share_dm_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=file_share_im_message_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + def test_app_home_opened_messages_tab_attaches_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=app_home_opened_messages_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "say" not in req.context + assert "set_title" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + + def test_app_home_opened_home_tab_does_not_attach_suggested_prompts(self): + middleware = AttachingConversationKwargs() + req = BoltRequest(body=app_home_opened_home_event_body, mode="socket_mode") + req.context["client"] = WebClient(token="xoxb-test") + + resp = middleware.process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" not in req.context + def test_non_assistant_event_does_not_attach_kwargs(self): middleware = AttachingConversationKwargs() req = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") diff --git a/tests/slack_bolt/request/test_payload_utils.py b/tests/slack_bolt/request/test_payload_utils.py new file mode 100644 index 000000000..576cb390d --- /dev/null +++ b/tests/slack_bolt/request/test_payload_utils.py @@ -0,0 +1,408 @@ +from slack_bolt.request.payload_utils import ( + is_event, + is_message_event, + is_any_im_message_event, + is_im_message_event, + is_assistant_event, + is_assistant_thread_started_event, + is_assistant_thread_context_changed_event, + is_app_home_opened_event, + is_user_message_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, + is_other_message_sub_event_in_assistant_thread, +) +from tests.scenario_tests.test_events_assistant import ( + build_payload, + 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", + } +) + +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", + "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_message_event(self): + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "slash_command": slash_command_body, + "empty_dict": {}, + } + for key, body in positives.items(): + assert is_message_event(body), f"{key} should be recognized as a message event" + for key, body in negatives.items(): + assert not is_message_event(body), f"{key} should NOT be recognized as a message event" + + def test_is_any_im_message_event(self): + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "message_changed_im": message_changed_event_body, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "bot_message_channel": bot_message_event_payload, + "classic_bot_message_channel": classic_bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "slash_command": slash_command_body, + } + for key, body in positives.items(): + assert is_any_im_message_event(body), f"{key} should pass {is_any_im_message_event.__name__}" + for key, body in negatives.items(): + assert not is_any_im_message_event(body), f"{key} should NOT pass {is_any_im_message_event.__name__}" + + def test_is_im_message_event(self): + # subtype must be None or "file_share" to pass + positives = { + "user_message_im": user_message_event_body, + "user_message_im_with_assistant_thread": user_message_event_body_with_assistant_thread, + "file_share_im": file_share_im_message_body, + "bot_im_thread": bot_im_thread_message_body, + "im_no_thread_ts": im_message_no_thread_ts_body, + } + negatives = { + "message_changed_im": message_changed_event_body, + "channel_user_message": channel_user_message_event_body, + "channel_message_changed": channel_message_changed_event_body, + "classic_bot_message_channel": classic_bot_message_event_payload, + "bot_message_channel": bot_message_event_payload, + "message_deleted_channel": message_deleted_channel_body, + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + } + for key, body in positives.items(): + assert is_im_message_event(body), f"{key} should pass {is_im_message_event.__name__}" + for key, body in negatives.items(): + assert not is_im_message_event(body), f"{key} should NOT pass {is_im_message_event.__name__}" + + def test_is_event(self): + positives = { + "thread_started": thread_started_event_body, + "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_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): + positives = { + "bot_im_thread": bot_im_thread_message_body, + } + negatives = { + "user_message_im": user_message_event_body, + "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): + 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) + + 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__}" + + def test_is_app_home_opened_event(self): + assert is_app_home_opened_event(app_home_opened_messages_body) + assert is_app_home_opened_event(app_home_opened_home_body) + + assert is_app_home_opened_event(app_home_opened_messages_body, tab="messages") + assert not is_app_home_opened_event(app_home_opened_home_body, tab="messages") + + negatives = { + "thread_started": thread_started_event_body, + "thread_context_changed": thread_context_changed_event_body, + "user_message_im": user_message_event_body, + "channel_user_message": channel_user_message_event_body, + "reaction_added": reaction_added_event_body, + "block_actions": block_actions_body, + "empty_dict": {}, + } + for key, body in negatives.items(): + assert not is_app_home_opened_event(body), f"{key} should NOT pass {is_app_home_opened_event.__name__}" + assert not is_app_home_opened_event( + body, tab="messages" + ), f"{key} should NOT pass {is_app_home_opened_event.__name__} with tab='messages'" diff --git a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py index 2a09434a8..7a92ff2a3 100644 --- a/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py +++ b/tests/slack_bolt_async/context/test_async_set_suggested_prompts.py @@ -1,4 +1,5 @@ import asyncio +from unittest.mock import MagicMock, patch import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -41,6 +42,65 @@ async def test_set_suggested_prompts_objects(self): ) assert response.status_code == 200 + @pytest.mark.asyncio + async def test_set_suggested_prompts_without_thread_ts(self): + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"]) + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts=None, + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_thread_ts_override(self): + # The call-time thread_ts must win over the stored one + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="999.999") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"], thread_ts="123.123") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="123.123", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + + @pytest.mark.asyncio + async def test_set_suggested_prompts_thread_ts_override_falsy(self): + # An explicitly passed falsy thread_ts must be forwarded, not swallowed by the stored value + set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") + mock_api = MagicMock() + + async def fake_api(**kwargs): + return mock_api(**kwargs) + + with patch.object( + self.web_client, self.web_client.assistant_threads_setSuggestedPrompts.__name__, side_effect=fake_api + ): + await set_suggested_prompts(prompts=["One", "Two"], thread_ts="") + mock_api.assert_called_once_with( + channel_id="C111", + thread_ts="", + prompts=[{"title": "One", "message": "One"}, {"title": "Two", "message": "Two"}], + title=None, + ) + @pytest.mark.asyncio async def test_set_suggested_prompts_invalid(self): set_suggested_prompts = AsyncSetSuggestedPrompts(client=self.web_client, channel_id="C111", thread_ts="123.123") diff --git a/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py index a00b35cd3..c7da4ce93 100644 --- a/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py +++ b/tests/slack_bolt_async/middleware/attaching_conversation_kwargs/test_async_attaching_conversation_kwargs.py @@ -7,6 +7,7 @@ from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from tests.scenario_tests_async.test_events_assistant import ( + build_payload, thread_started_event_body, user_message_event_body, channel_user_message_event_body, @@ -19,6 +20,70 @@ async def next(): ASSISTANT_KWARGS = ("say", "set_title", "set_suggested_prompts", "get_thread_context", "save_thread_context") +# A top-level DM (not in a thread) is not an assistant thread, but set_suggested_prompts is still attached. +top_level_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "A top-level DM, not in a thread", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A bot-authored top-level DM is also in scope: set_suggested_prompts is attached for any IM message. +bot_im_message_event_body = build_payload( + { + "type": "message", + "ts": "1726133700.887259", + "text": "A DM authored by a bot", + "user": "UB111", + "bot_id": "B111", + "app_id": "A222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# A file_share DM is in scope too (subtype "file_share" passes is_im_message_event). +file_share_im_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "subtype": "file_share", + "ts": "1726133700.887259", + "text": "uploaded a file", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + +# Opening the Messages tab of App Home is in scope for set_suggested_prompts. +app_home_opened_messages_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "messages", + "event_ts": "1726133700.887259", + } +) + +# Opening the Home tab is NOT in scope: set_suggested_prompts should not be attached. +app_home_opened_home_event_body = build_payload( + { + "type": "app_home_opened", + "user": "W222", + "channel": "D111", + "tab": "home", + "event_ts": "1726133700.887259", + } +) + class TestAsyncAttachingConversationKwargs: @pytest.mark.asyncio @@ -51,6 +116,71 @@ async def test_user_message_event_attaches_kwargs(self): assert "say_stream" in req.context assert "set_status" in req.context + @pytest.mark.asyncio + async def test_top_level_dm_attaches_suggested_prompts_but_not_set_title(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=top_level_im_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "set_title" not in req.context + assert "say" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + assert "say_stream" in req.context + assert "set_status" in req.context + + @pytest.mark.asyncio + async def test_bot_dm_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=bot_im_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + @pytest.mark.asyncio + async def test_file_share_dm_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=file_share_im_message_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + + @pytest.mark.asyncio + async def test_app_home_opened_messages_tab_attaches_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=app_home_opened_messages_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" in req.context + assert "say" not in req.context + assert "set_title" not in req.context + assert "get_thread_context" not in req.context + assert "save_thread_context" not in req.context + + @pytest.mark.asyncio + async def test_app_home_opened_home_tab_does_not_attach_suggested_prompts(self): + middleware = AsyncAttachingConversationKwargs() + req = AsyncBoltRequest(body=app_home_opened_home_event_body, mode="socket_mode") + req.context["client"] = AsyncWebClient(token="xoxb-test") + + resp = await middleware.async_process(req=req, resp=BoltResponse(status=404), next=next) + + assert resp.status == 200 + assert "set_suggested_prompts" not in req.context + @pytest.mark.asyncio async def test_non_assistant_event_does_not_attach_kwargs(self): middleware = AsyncAttachingConversationKwargs()