From e6ef608504258b7584909d6b1b7c9fdaeb64b2bc Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 19 Sep 2024 10:28:12 +0900 Subject: [PATCH] Add Agents & Assistants feature --- examples/assistants/app.py | 95 ++++++ examples/assistants/async_app.py | 97 ++++++ examples/assistants/async_interaction_app.py | 320 ++++++++++++++++++ examples/assistants/interaction_app.py | 155 +++++++++ requirements.txt | 2 +- slack_bolt/__init__.py | 21 ++ slack_bolt/app/app.py | 50 ++- slack_bolt/app/async_app.py | 46 ++- slack_bolt/async_app.py | 12 + slack_bolt/context/assistant/__init__.py | 1 + .../context/assistant/assistant_utilities.py | 81 +++++ .../assistant/async_assistant_utilities.py | 87 +++++ .../assistant/thread_context/__init__.py | 13 + .../thread_context_store/__init__.py | 1 + .../thread_context_store/async_store.py | 11 + .../default_async_store.py | 55 +++ .../thread_context_store/default_store.py | 50 +++ .../thread_context_store/file/__init__.py | 37 ++ .../assistant/thread_context_store/store.py | 11 + slack_bolt/context/async_context.py | 27 +- slack_bolt/context/base_context.py | 15 + slack_bolt/context/context.py | 27 +- .../context/get_thread_context/__init__.py | 6 + .../async_get_thread_context.py | 48 +++ .../get_thread_context/get_thread_context.py | 48 +++ .../context/save_thread_context/__init__.py | 6 + .../async_save_thread_context.py | 26 ++ .../save_thread_context.py | 26 ++ slack_bolt/context/say/async_say.py | 16 +- slack_bolt/context/say/say.py | 14 +- slack_bolt/context/set_status/__init__.py | 6 + .../context/set_status/async_set_status.py | 25 ++ slack_bolt/context/set_status/set_status.py | 25 ++ .../context/set_suggested_prompts/__init__.py | 6 + .../async_set_suggested_prompts.py | 34 ++ .../set_suggested_prompts.py | 34 ++ slack_bolt/context/set_title/__init__.py | 6 + .../context/set_title/async_set_title.py | 25 ++ slack_bolt/context/set_title/set_title.py | 25 ++ slack_bolt/kwargs_injection/args.py | 27 ++ slack_bolt/kwargs_injection/async_args.py | 27 ++ slack_bolt/kwargs_injection/async_utils.py | 5 + slack_bolt/kwargs_injection/utils.py | 4 + slack_bolt/listener/asyncio_runner.py | 4 + slack_bolt/listener/thread_runner.py | 5 + slack_bolt/middleware/__init__.py | 1 + slack_bolt/middleware/assistant/__init__.py | 6 + slack_bolt/middleware/assistant/assistant.py | 291 ++++++++++++++++ .../middleware/assistant/async_assistant.py | 320 ++++++++++++++++++ .../single_team_authorization.py | 1 + .../async_ignoring_self_events.py | 6 + .../ignoring_self_events.py | 13 +- slack_bolt/request/async_internals.py | 4 + slack_bolt/request/internals.py | 29 ++ slack_bolt/request/payload_utils.py | 67 ++++ slack_bolt/util/utils.py | 12 + tests/scenario_tests/test_events_assistant.py | 259 ++++++++++++++ .../test_events_assistant.py | 274 +++++++++++++++ 58 files changed, 2934 insertions(+), 11 deletions(-) create mode 100644 examples/assistants/app.py create mode 100644 examples/assistants/async_app.py create mode 100644 examples/assistants/async_interaction_app.py create mode 100644 examples/assistants/interaction_app.py create mode 100644 slack_bolt/context/assistant/__init__.py create mode 100644 slack_bolt/context/assistant/assistant_utilities.py create mode 100644 slack_bolt/context/assistant/async_assistant_utilities.py create mode 100644 slack_bolt/context/assistant/thread_context/__init__.py create mode 100644 slack_bolt/context/assistant/thread_context_store/__init__.py create mode 100644 slack_bolt/context/assistant/thread_context_store/async_store.py create mode 100644 slack_bolt/context/assistant/thread_context_store/default_async_store.py create mode 100644 slack_bolt/context/assistant/thread_context_store/default_store.py create mode 100644 slack_bolt/context/assistant/thread_context_store/file/__init__.py create mode 100644 slack_bolt/context/assistant/thread_context_store/store.py create mode 100644 slack_bolt/context/get_thread_context/__init__.py create mode 100644 slack_bolt/context/get_thread_context/async_get_thread_context.py create mode 100644 slack_bolt/context/get_thread_context/get_thread_context.py create mode 100644 slack_bolt/context/save_thread_context/__init__.py create mode 100644 slack_bolt/context/save_thread_context/async_save_thread_context.py create mode 100644 slack_bolt/context/save_thread_context/save_thread_context.py create mode 100644 slack_bolt/context/set_status/__init__.py create mode 100644 slack_bolt/context/set_status/async_set_status.py create mode 100644 slack_bolt/context/set_status/set_status.py create mode 100644 slack_bolt/context/set_suggested_prompts/__init__.py create mode 100644 slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py create mode 100644 slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py create mode 100644 slack_bolt/context/set_title/__init__.py create mode 100644 slack_bolt/context/set_title/async_set_title.py create mode 100644 slack_bolt/context/set_title/set_title.py create mode 100644 slack_bolt/middleware/assistant/__init__.py create mode 100644 slack_bolt/middleware/assistant/assistant.py create mode 100644 slack_bolt/middleware/assistant/async_assistant.py create mode 100644 tests/scenario_tests/test_events_assistant.py create mode 100644 tests/scenario_tests_async/test_events_assistant.py diff --git a/examples/assistants/app.py b/examples/assistants/app.py new file mode 100644 index 000000000..256f593de --- /dev/null +++ b/examples/assistants/app.py @@ -0,0 +1,95 @@ +import logging +import os +import time + +from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App, Assistant, SetStatus, SetTitle, SetSuggestedPrompts, Say +from slack_bolt.adapter.socket_mode import SocketModeHandler + +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + + +assistant = Assistant() +# You can use your own thread_context_store if you want +# from slack_bolt.slack_sdk_assistant.thread_context_store import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts): + say(":wave: Hi, how can I help you today?") + set_suggested_prompts( + prompts=[ + "What does SLACK stand for?", + "When Slack was released?", + ] + ) + + +@assistant.user_message(matchers=[lambda payload: "help page" in payload["text"]]) +def find_help_pages( + payload: dict, + logger: logging.Logger, + set_title: SetTitle, + set_status: SetStatus, + say: Say, +): + try: + set_title(payload["text"]) + set_status("Searching help pages...") + time.sleep(0.5) + say("Please check this help page: https://www.example.com/help-page-123") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +@assistant.user_message +def answer_other_inquiries( + payload: dict, + logger: logging.Logger, + set_title: SetTitle, + set_status: SetStatus, + say: Say, + get_thread_context: GetThreadContext, +): + try: + set_title(payload["text"]) + set_status("Typing...") + time.sleep(0.3) + set_status("Still typing...") + time.sleep(0.3) + thread_context = get_thread_context() + if thread_context is not None: + channel = thread_context.channel_id + say(f"Ah, you're referring to <#{channel}>! Do you need help with the channel?") + else: + say("Here you are! blah-blah-blah...") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +def handle_non_assistant_thread_messages(say: Say): + say(":wave: I can help you out within our 1:1 DM!") + + +if __name__ == "__main__": + SocketModeHandler(app, app_token=os.environ["SLACK_APP_TOKEN"]).start() + +# pip install slack_bolt +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python app.py diff --git a/examples/assistants/async_app.py b/examples/assistants/async_app.py new file mode 100644 index 000000000..be7475a6f --- /dev/null +++ b/examples/assistants/async_app.py @@ -0,0 +1,97 @@ +import logging +import os +import asyncio + +from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp, AsyncAssistant, AsyncSetTitle, AsyncSetStatus, AsyncSetSuggestedPrompts, AsyncSay +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + + +assistant = AsyncAssistant() + + +@assistant.thread_started +async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPrompts): + await say(":wave: Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[ + "What does SLACK stand for?", + "When Slack was released?", + ] + ) + + +@assistant.user_message(matchers=[lambda body: "help page" in body["event"]["text"]]) +async def find_help_pages( + payload: dict, + logger: logging.Logger, + set_title: AsyncSetTitle, + set_status: AsyncSetStatus, + say: AsyncSay, +): + try: + await set_title(payload["text"]) + await set_status("Searching help pages...") + await asyncio.sleep(0.5) + await say("Please check this help page: https://www.example.com/help-page-123") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +@assistant.user_message +async def answer_other_inquiries( + payload: dict, + logger: logging.Logger, + set_title: AsyncSetTitle, + set_status: AsyncSetStatus, + say: AsyncSay, + get_thread_context: AsyncGetThreadContext, +): + try: + await set_title(payload["text"]) + await set_status("Typing...") + await asyncio.sleep(0.3) + await set_status("Still typing...") + await asyncio.sleep(0.3) + thread_context = await get_thread_context() + if thread_context is not None: + channel = thread_context.channel_id + await say(f"Ah, you're referring to <#{channel}>! Do you need help with the channel?") + else: + await say("Here you are! blah-blah-blah...") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +async def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +async def handle_non_assistant_thread_messages(say: AsyncSay): + await say(":wave: I can help you out within our 1:1 DM!") + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + asyncio.run(main()) + +# pip install slack_bolt aiohttp +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python async_app.py diff --git a/examples/assistants/async_interaction_app.py b/examples/assistants/async_interaction_app.py new file mode 100644 index 000000000..9dab7aadf --- /dev/null +++ b/examples/assistants/async_interaction_app.py @@ -0,0 +1,320 @@ +# flake8: noqa F811 +import asyncio +import logging +import os +import random +import json + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp, AsyncAssistant, AsyncSetStatus, AsyncSay, AsyncAck +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from slack_sdk.web.async_client import AsyncWebClient + +app = AsyncApp( + token=os.environ["SLACK_BOT_TOKEN"], + # This must be set to handle bot message events + ignoring_self_assistant_message_events_enabled=False, +) + + +assistant = AsyncAssistant() +# You can use your own thread_context_store if you want +# from slack_bolt.slack_sdk_assistant.thread_context_store import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +async def start_thread(say: AsyncSay): + await say( + text=":wave: Hi, how can I help you today?", + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": ":wave: Hi, how can I help you today?"}, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "assistant-generate-random-numbers", + "text": {"type": "plain_text", "text": "Generate random numbers"}, + "value": "1", + }, + ], + }, + ], + ) + + +@app.action("assistant-generate-random-numbers") +async def configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, body: dict): + await ack() + await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "configure_assistant_summarize_channel", + "title": {"type": "plain_text", "text": "My Assistant"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "private_metadata": json.dumps( + { + "channel_id": body["channel"]["id"], + "thread_ts": body["message"]["thread_ts"], + } + ), + "blocks": [ + { + "type": "input", + "block_id": "num", + "label": {"type": "plain_text", "text": "# of outputs"}, + "element": { + "type": "static_select", + "action_id": "input", + "placeholder": {"type": "plain_text", "text": "How many numbers do you need?"}, + "options": [ + {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + {"text": {"type": "plain_text", "text": "10"}, "value": "10"}, + {"text": {"type": "plain_text", "text": "20"}, "value": "20"}, + ], + "initial_option": {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + }, + } + ], + }, + ) + + +@app.view("configure_assistant_summarize_channel") +async def receive_configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, payload: dict): + await ack() + num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] + thread = json.loads(payload["private_metadata"]) + await client.chat_postMessage( + channel=thread["channel_id"], + thread_ts=thread["thread_ts"], + text=f"OK, you need {num} numbers. I will generate it shortly!", + metadata={ + "event_type": "assistant-generate-random-numbers", + "event_payload": {"num": int(num)}, + }, + ) + + +@assistant.bot_message +async def respond_to_bot_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay, payload: dict): + try: + if payload.get("metadata", {}).get("event_type") == "assistant-generate-random-numbers": + await set_status("is generating an array of random numbers...") + await asyncio.sleep(1) + nums: Set[str] = set() + num = payload["metadata"]["event_payload"]["num"] + while len(nums) < num: + nums.add(str(random.randint(1, 100))) + await say(f"Here you are: {', '.join(nums)}") + else: + # nothing to do for this bot message + # If you want to add more patterns here, be careful not to cause infinite loop messaging + pass + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + + +@assistant.user_message +async def respond_to_user_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay): + try: + await set_status("is typing...") + await say("Sorry, I couldn't understand your comment. Could you say it in a different way?") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +async def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +async def handle_non_assistant_thread_messages(say: AsyncSay): + await say(":wave: I can help you out within our 1:1 DM!") + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + asyncio.run(main()) + +# pip install slack_bolt aiohttp +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python async_interaction_app.py +import asyncio +import json +import logging +import os +from typing import Set +import random + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt.async_app import AsyncApp, AsyncAssistant, AsyncSetStatus, AsyncSay, AsyncAck +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from slack_sdk.web.async_client import AsyncWebClient + +app = AsyncApp( + token=os.environ["SLACK_BOT_TOKEN"], + # This must be set to handle bot message events + ignoring_self_assistant_message_events_enabled=False, +) + + +assistant = AsyncAssistant() +# You can use your own thread_context_store if you want +# from slack_bolt.slack_sdk_assistant.thread_context_store import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +async def start_thread(say: AsyncSay): + await say( + text=":wave: Hi, how can I help you today?", + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": ":wave: Hi, how can I help you today?"}, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "assistant-generate-random-numbers", + "text": {"type": "plain_text", "text": "Generate random numbers"}, + "value": "1", + }, + ], + }, + ], + ) + + +@app.action("assistant-generate-random-numbers") +async def configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, body: dict): + await ack() + await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "configure_assistant_summarize_channel", + "title": {"type": "plain_text", "text": "My Assistant"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "private_metadata": json.dumps( + { + "channel_id": body["channel"]["id"], + "thread_ts": body["message"]["thread_ts"], + } + ), + "blocks": [ + { + "type": "input", + "block_id": "num", + "label": {"type": "plain_text", "text": "# of outputs"}, + "element": { + "type": "static_select", + "action_id": "input", + "placeholder": {"type": "plain_text", "text": "How many numbers do you need?"}, + "options": [ + {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + {"text": {"type": "plain_text", "text": "10"}, "value": "10"}, + {"text": {"type": "plain_text", "text": "20"}, "value": "20"}, + ], + "initial_option": {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + }, + } + ], + }, + ) + + +@app.view("configure_assistant_summarize_channel") +async def receive_configure_assistant_summarize_channel(ack: AsyncAck, client: AsyncWebClient, payload: dict): + await ack() + num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] + thread = json.loads(payload["private_metadata"]) + await client.chat_postMessage( + channel=thread["channel_id"], + thread_ts=thread["thread_ts"], + text=f"OK, you need {num} numbers. I will generate it shortly!", + metadata={ + "event_type": "assistant-generate-random-numbers", + "event_payload": {"num": int(num)}, + }, + ) + + +@assistant.bot_message +async def respond_to_bot_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay, payload: dict): + try: + if payload.get("metadata", {}).get("event_type") == "assistant-generate-random-numbers": + await set_status("is generating an array of random numbers...") + await asyncio.sleep(1) + nums: Set[str] = set() + num = payload["metadata"]["event_payload"]["num"] + while len(nums) < num: + nums.add(str(random.randint(1, 100))) + await say(f"Here you are: {', '.join(nums)}") + else: + # nothing to do for this bot message + # If you want to add more patterns here, be careful not to cause infinite loop messaging + pass + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + + +@assistant.user_message +async def respond_to_user_messages(logger: logging.Logger, set_status: AsyncSetStatus, say: AsyncSay): + try: + await set_status("is typing...") + await say("Sorry, I couldn't understand your comment. Could you say it in a different way?") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + await say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +async def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +async def handle_non_assistant_thread_messages(say: AsyncSay): + await say(":wave: I can help you out within our 1:1 DM!") + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + asyncio.run(main()) + +# pip install slack_bolt aiohttp +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python async_interaction_app.py diff --git a/examples/assistants/interaction_app.py b/examples/assistants/interaction_app.py new file mode 100644 index 000000000..c300270d3 --- /dev/null +++ b/examples/assistants/interaction_app.py @@ -0,0 +1,155 @@ +import json +import logging +import os +from typing import Set +import random +import time + +logging.basicConfig(level=logging.DEBUG) + +from slack_bolt import App, Assistant, SetStatus, Say, Ack +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient + +app = App( + token=os.environ["SLACK_BOT_TOKEN"], + # This must be set to handle bot message events + ignoring_self_assistant_message_events_enabled=False, +) + + +assistant = Assistant() +# You can use your own thread_context_store if you want +# from slack_bolt.slack_sdk_assistant.thread_context_store import FileAssistantThreadContextStore +# assistant = Assistant(thread_context_store=FileAssistantThreadContextStore()) + + +@assistant.thread_started +def start_thread(say: Say): + say( + text=":wave: Hi, how can I help you today?", + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": ":wave: Hi, how can I help you today?"}, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "assistant-generate-random-numbers", + "text": {"type": "plain_text", "text": "Generate random numbers"}, + "value": "1", + }, + ], + }, + ], + ) + + +@app.action("assistant-generate-random-numbers") +def configure_assistant_summarize_channel(ack: Ack, client: WebClient, body: dict): + ack() + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "configure_assistant_summarize_channel", + "title": {"type": "plain_text", "text": "My Assistant"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "private_metadata": json.dumps( + { + "channel_id": body["channel"]["id"], + "thread_ts": body["message"]["thread_ts"], + } + ), + "blocks": [ + { + "type": "input", + "block_id": "num", + "label": {"type": "plain_text", "text": "# of outputs"}, + "element": { + "type": "static_select", + "action_id": "input", + "placeholder": {"type": "plain_text", "text": "How many numbers do you need?"}, + "options": [ + {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + {"text": {"type": "plain_text", "text": "10"}, "value": "10"}, + {"text": {"type": "plain_text", "text": "20"}, "value": "20"}, + ], + "initial_option": {"text": {"type": "plain_text", "text": "5"}, "value": "5"}, + }, + } + ], + }, + ) + + +@app.view("configure_assistant_summarize_channel") +def receive_configure_assistant_summarize_channel(ack: Ack, client: WebClient, payload: dict): + ack() + num = payload["state"]["values"]["num"]["input"]["selected_option"]["value"] + thread = json.loads(payload["private_metadata"]) + client.chat_postMessage( + channel=thread["channel_id"], + thread_ts=thread["thread_ts"], + text=f"OK, you need {num} numbers. I will generate it shortly!", + metadata={ + "event_type": "assistant-generate-random-numbers", + "event_payload": {"num": int(num)}, + }, + ) + + +@assistant.bot_message +def respond_to_bot_messages(logger: logging.Logger, set_status: SetStatus, say: Say, payload: dict): + try: + if payload.get("metadata", {}).get("event_type") == "assistant-generate-random-numbers": + set_status("is generating an array of random numbers...") + time.sleep(1) + nums: Set[str] = set() + num = payload["metadata"]["event_payload"]["num"] + while len(nums) < num: + nums.add(str(random.randint(1, 100))) + say(f"Here you are: {', '.join(nums)}") + else: + # nothing to do for this bot message + # If you want to add more patterns here, be careful not to cause infinite loop messaging + pass + + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + + +@assistant.user_message +def respond_to_user_messages(logger: logging.Logger, set_status: SetStatus, say: Say): + try: + set_status("is typing...") + say("Sorry, I couldn't understand your comment. Could you say it in a different way?") + except Exception as e: + logger.exception(f"Failed to respond to an inquiry: {e}") + say(f":warning: Sorry, something went wrong during processing your request (error: {e})") + + +app.use(assistant) + + +@app.event("message") +def handle_message_in_channels(): + pass # noop + + +@app.event("app_mention") +def handle_non_assistant_thread_messages(say: Say): + say(":wave: I can help you out within our 1:1 DM!") + + +if __name__ == "__main__": + SocketModeHandler(app, app_token=os.environ["SLACK_APP_TOKEN"]).start() + +# pip install slack_bolt +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** +# python interaction_app.py diff --git a/requirements.txt b/requirements.txt index e2980e2d6..9bc045034 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -slack_sdk>=3.26.0,<4 +slack_sdk>=3.33.0,<4 diff --git a/slack_bolt/__init__.py b/slack_bolt/__init__.py index 789b93c92..32ab76721 100644 --- a/slack_bolt/__init__.py +++ b/slack_bolt/__init__.py @@ -20,6 +20,19 @@ from .request import BoltRequest from .response import BoltResponse +# AI Agents & Assistants +from .middleware.assistant.assistant import ( + Assistant, +) +from .context.assistant.thread_context import AssistantThreadContext +from .context.assistant.thread_context_store.store import AssistantThreadContextStore +from .context.assistant.thread_context_store.file import FileAssistantThreadContextStore + +from .context.set_status import SetStatus +from .context.set_title import SetTitle +from .context.set_suggested_prompts import SetSuggestedPrompts +from .context.save_thread_context import SaveThreadContext + __all__ = [ "App", "BoltContext", @@ -33,4 +46,12 @@ "CustomListenerMatcher", "BoltRequest", "BoltResponse", + "Assistant", + "AssistantThreadContext", + "AssistantThreadContextStore", + "FileAssistantThreadContextStore", + "SetStatus", + "SetTitle", + "SetSuggestedPrompts", + "SaveThreadContext", ] diff --git a/slack_bolt/app/app.py b/slack_bolt/app/app.py index c72394821..27ebbbb29 100644 --- a/slack_bolt/app/app.py +++ b/slack_bolt/app/app.py @@ -19,6 +19,10 @@ InstallationStoreAuthorize, CallableAuthorize, ) + +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + +from slack_bolt.context.assistant.assistant_utilities import AssistantUtilities from slack_bolt.error import BoltError, BoltUnhandledRequestError from slack_bolt.lazy_listener.thread_runner import ThreadLazyListenerRunner from slack_bolt.listener.builtins import TokenRevocationListeners @@ -66,6 +70,7 @@ CustomMiddleware, AttachingFunctionToken, ) +from slack_bolt.middleware.assistant import Assistant from slack_bolt.middleware.message_listener_matches import MessageListenerMatches from slack_bolt.middleware.middleware_error_handler import ( DefaultMiddlewareErrorHandler, @@ -77,6 +82,10 @@ from slack_bolt.oauth.internals import select_consistent_installation_store from slack_bolt.oauth.oauth_settings import OAuthSettings from slack_bolt.request import BoltRequest +from slack_bolt.request.payload_utils import ( + is_assistant_event, + to_event, +) from slack_bolt.response import BoltResponse from slack_bolt.util.utils import ( create_web_client, @@ -114,6 +123,7 @@ def __init__( # for customizing the built-in middleware request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, + ignoring_self_assistant_message_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, attaching_function_token_enabled: bool = True, @@ -124,6 +134,8 @@ def __init__( verification_token: Optional[str] = None, # Set this one only when you want to customize the executor listener_executor: Optional[Executor] = None, + # for AI Agents & Assistants + assistant_thread_context_store: Optional[AssistantThreadContextStore] = None, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -179,6 +191,9 @@ def message_hello(message, say): ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). `IgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware. + `IgnoringSelfEvents` for this app's bot user message events within an assistant thread + This is useful for avoiding code error causing an infinite loop; Default: True url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). `UrlVerification` is a built-in middleware that handles url_verification requests that verify the endpoint for Events API in HTTP Mode requests. @@ -192,6 +207,8 @@ def message_hello(message, say): verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. listener_executor: Custom executor to run background tasks. If absent, the default `ThreadPoolExecutor` will be used. + assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation, + which uses a parent message's metadata to store the latest context) """ if signing_secret is None: signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "") @@ -338,6 +355,8 @@ def message_hello(message, say): if listener_executor is None: listener_executor = ThreadPoolExecutor(max_workers=5) + self._assistant_thread_context_store = assistant_thread_context_store + self._process_before_response = process_before_response self._listener_runner = ThreadListenerRunner( logger=self._framework_logger, @@ -360,6 +379,7 @@ def message_hello(message, say): token_verification_enabled=token_verification_enabled, request_verification_enabled=request_verification_enabled, ignoring_self_events_enabled=ignoring_self_events_enabled, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, attaching_function_token_enabled=attaching_function_token_enabled, @@ -371,6 +391,7 @@ def _init_middleware_list( token_verification_enabled: bool = True, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, + ignoring_self_assistant_message_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, attaching_function_token_enabled: bool = True, @@ -431,7 +452,12 @@ def _init_middleware_list( raise BoltError(error_oauth_flow_or_authorize_required()) if ignoring_self_events_enabled is True: - self._middleware_list.append(IgnoringSelfEvents(base_logger=self._base_logger)) + self._middleware_list.append( + IgnoringSelfEvents( + base_logger=self._base_logger, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, + ) + ) if url_verification_enabled is True: self._middleware_list.append(UrlVerification(base_logger=self._base_logger)) if attaching_function_token_enabled is True: @@ -656,6 +682,8 @@ def middleware_func(logger, body, next): if isinstance(middleware_or_callable, Middleware): middleware: Middleware = middleware_or_callable self._middleware_list.append(middleware) + if isinstance(middleware, Assistant) and middleware.thread_context_store is not None: + self._assistant_thread_context_store = middleware.thread_context_store elif callable(middleware_or_callable): self._middleware_list.append( CustomMiddleware( @@ -669,6 +697,12 @@ def middleware_func(logger, body, next): raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})") return None + # ------------------------- + # AI Agents & Assistants + + def assistant(self, assistant: Assistant) -> Optional[Callable]: + return self.middleware(assistant) + # ------------------------- # Workflows: Steps from apps @@ -1354,6 +1388,20 @@ def _init_context(self, req: BoltRequest): # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner + # For AI Agents & Assistants + if is_assistant_event(req.body): + assistant = AssistantUtilities( + payload=to_event(req.body), # type:ignore[arg-type] + context=req.context, + thread_context_store=self._assistant_thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_status"] = assistant.set_status + 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 + @staticmethod def _to_listener_functions( kwargs: dict, diff --git a/slack_bolt/app/async_app.py b/slack_bolt/app/async_app.py index 92bad71b7..e2100b23e 100644 --- a/slack_bolt/app/async_app.py +++ b/slack_bolt/app/async_app.py @@ -8,6 +8,10 @@ from aiohttp import web from slack_bolt.app.async_server import AsyncSlackAppServer +from slack_bolt.context.assistant.async_assistant_utilities import AsyncAssistantUtilities +from slack_bolt.context.assistant.thread_context_store.async_store import ( + AsyncAssistantThreadContextStore, +) from slack_bolt.listener.async_builtins import AsyncTokenRevocationListeners from slack_bolt.listener.async_listener_start_handler import ( AsyncDefaultListenerStartHandler, @@ -16,6 +20,7 @@ AsyncDefaultListenerCompletionHandler, ) from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.middleware.assistant.async_assistant import AsyncAssistant from slack_bolt.middleware.async_middleware_error_handler import ( AsyncCustomMiddlewareErrorHandler, AsyncDefaultMiddlewareErrorHandler, @@ -25,6 +30,7 @@ AsyncMessageListenerMatches, ) from slack_bolt.oauth.async_internals import select_consistent_installation_store +from slack_bolt.request.payload_utils import is_assistant_event, to_event from slack_bolt.util.utils import get_name_for_callable, is_callable_coroutine from slack_bolt.workflows.step.async_step import ( AsyncWorkflowStep, @@ -125,6 +131,7 @@ def __init__( # for customizing the built-in middleware request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, + ignoring_self_assistant_message_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, attaching_function_token_enabled: bool = True, @@ -133,6 +140,8 @@ def __init__( oauth_flow: Optional[AsyncOAuthFlow] = None, # No need to set (the value is used only in response to ssl_check requests) verification_token: Optional[str] = None, + # for AI Agents & Assistants + assistant_thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, ): """Bolt App that provides functionalities to register middleware/listeners. @@ -187,6 +196,9 @@ async def message_hello(message, say): # async function ignoring_self_events_enabled: False if you would like to disable the built-in middleware (Default: True). `AsyncIgnoringSelfEvents` is a built-in middleware that enables Bolt apps to easily skip the events generated by this app's bot user (this is useful for avoiding code error causing an infinite loop). + ignoring_self_assistant_message_events_enabled: False if you would like to disable the built-in middleware. + `IgnoringSelfEvents` for this app's bot user message events within an assistant thread + This is useful for avoiding code error causing an infinite loop; Default: True url_verification_enabled: False if you would like to disable the built-in middleware (Default: True). `AsyncUrlVerification` is a built-in middleware that handles url_verification requests that verify the endpoint for Events API in HTTP Mode requests. @@ -197,7 +209,9 @@ async def message_hello(message, say): # async function when your app receives `function_executed` or interactivity events scoped to a custom step. oauth_settings: The settings related to Slack app installation flow (OAuth flow) oauth_flow: Instantiated `slack_bolt.oauth.AsyncOAuthFlow`. This is always prioritized over oauth_settings. - verification_token: Deprecated verification mechanism. This can used only for ssl_check requests. + verification_token: Deprecated verification mechanism. This can be used only for ssl_check requests. + assistant_thread_context_store: Custom AssistantThreadContext store (Default: the built-in implementation, + which uses a parent message's metadata to store the latest context) """ if signing_secret is None: signing_secret = os.environ.get("SLACK_SIGNING_SECRET", "") @@ -347,6 +361,8 @@ async def message_hello(message, say): # async function self._async_middleware_list: List[AsyncMiddleware] = [] self._async_listeners: List[AsyncListener] = [] + self._assistant_thread_context_store = assistant_thread_context_store + self._process_before_response = process_before_response self._async_listener_runner = AsyncioListenerRunner( logger=self._framework_logger, @@ -366,6 +382,7 @@ async def message_hello(message, say): # async function self._init_async_middleware_list( request_verification_enabled=request_verification_enabled, ignoring_self_events_enabled=ignoring_self_events_enabled, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, ssl_check_enabled=ssl_check_enabled, url_verification_enabled=url_verification_enabled, attaching_function_token_enabled=attaching_function_token_enabled, @@ -378,6 +395,7 @@ def _init_async_middleware_list( self, request_verification_enabled: bool = True, ignoring_self_events_enabled: bool = True, + ignoring_self_assistant_message_events_enabled: bool = True, ssl_check_enabled: bool = True, url_verification_enabled: bool = True, attaching_function_token_enabled: bool = True, @@ -430,7 +448,12 @@ def _init_async_middleware_list( raise BoltError(error_oauth_flow_or_authorize_required()) if ignoring_self_events_enabled is True: - self._async_middleware_list.append(AsyncIgnoringSelfEvents(base_logger=self._base_logger)) + self._async_middleware_list.append( + AsyncIgnoringSelfEvents( + base_logger=self._base_logger, + ignoring_self_assistant_message_events_enabled=ignoring_self_assistant_message_events_enabled, + ) + ) if url_verification_enabled is True: self._async_middleware_list.append(AsyncUrlVerification(base_logger=self._base_logger)) if attaching_function_token_enabled is True: @@ -683,6 +706,8 @@ async def middleware_func(logger, body, next): if isinstance(middleware_or_callable, AsyncMiddleware): middleware: AsyncMiddleware = middleware_or_callable self._async_middleware_list.append(middleware) + if isinstance(middleware, AsyncAssistant) and middleware.thread_context_store is not None: + self._assistant_thread_context_store = middleware.thread_context_store elif callable(middleware_or_callable): self._async_middleware_list.append( AsyncCustomMiddleware( @@ -696,6 +721,9 @@ async def middleware_func(logger, body, next): raise BoltError(f"Unexpected type for a middleware ({type(middleware_or_callable)})") return None + def assistant(self, assistant: AsyncAssistant) -> Optional[Callable]: + return self.middleware(assistant) + # ------------------------- # Workflows: Steps from apps @@ -1394,6 +1422,20 @@ def _init_context(self, req: AsyncBoltRequest): # It is intended for apps that start lazy listeners from their custom global middleware. req.context["listener_runner"] = self.listener_runner + # For AI Agents & Assistants + if is_assistant_event(req.body): + assistant = AsyncAssistantUtilities( + payload=to_event(req.body), # type:ignore[arg-type] + context=req.context, + thread_context_store=self._assistant_thread_context_store, + ) + req.context["say"] = assistant.say + req.context["set_status"] = assistant.set_status + 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 + @staticmethod def _to_listener_functions( kwargs: dict, diff --git a/slack_bolt/async_app.py b/slack_bolt/async_app.py index 3157e8006..10878c51b 100644 --- a/slack_bolt/async_app.py +++ b/slack_bolt/async_app.py @@ -53,6 +53,12 @@ async def command(ack, body, respond): from .listener.async_listener import AsyncListener from .listener_matcher.async_listener_matcher import AsyncCustomListenerMatcher from .request.async_request import AsyncBoltRequest +from .middleware.assistant.async_assistant import AsyncAssistant +from .context.set_status.async_set_status import AsyncSetStatus +from .context.set_title.async_set_title import AsyncSetTitle +from .context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from .context.get_thread_context.async_get_thread_context import AsyncGetThreadContext +from .context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext __all__ = [ "AsyncApp", @@ -63,4 +69,10 @@ async def command(ack, body, respond): "AsyncListener", "AsyncCustomListenerMatcher", "AsyncBoltRequest", + "AsyncAssistant", + "AsyncSetStatus", + "AsyncSetTitle", + "AsyncSetSuggestedPrompts", + "AsyncGetThreadContext", + "AsyncSaveThreadContext", ] diff --git a/slack_bolt/context/assistant/__init__.py b/slack_bolt/context/assistant/__init__.py new file mode 100644 index 000000000..c761cec3a --- /dev/null +++ b/slack_bolt/context/assistant/__init__.py @@ -0,0 +1 @@ +# Don't add async module imports here diff --git a/slack_bolt/context/assistant/assistant_utilities.py b/slack_bolt/context/assistant/assistant_utilities.py new file mode 100644 index 000000000..6746ec286 --- /dev/null +++ b/slack_bolt/context/assistant/assistant_utilities.py @@ -0,0 +1,81 @@ +from typing import Optional + +from slack_sdk.web import WebClient +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore +from slack_bolt.context.assistant.thread_context_store.default_store import DefaultAssistantThreadContextStore + + +from slack_bolt.context.context import BoltContext +from slack_bolt.context.say import Say +from ..get_thread_context.get_thread_context import GetThreadContext +from ..save_thread_context import SaveThreadContext +from ..set_status import SetStatus +from ..set_suggested_prompts import SetSuggestedPrompts +from ..set_title import SetTitle + + +class AssistantUtilities: + payload: dict + client: WebClient + channel_id: str + thread_ts: str + thread_context_store: AssistantThreadContextStore + + def __init__( + self, + *, + payload: dict, + context: BoltContext, + thread_context_store: Optional[AssistantThreadContextStore] = None, + ): + self.payload = payload + self.client = context.client + self.thread_context_store = thread_context_store or DefaultAssistantThreadContextStore(context) + + if self.payload.get("assistant_thread") is not None: + # assistant_thread_started + thread = self.payload["assistant_thread"] + self.channel_id = thread["channel_id"] + self.thread_ts = thread["thread_ts"] + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self.channel_id = self.payload["channel"] + self.thread_ts = self.payload["thread_ts"] + else: + # When moving this code to Bolt internals, no need to raise an exception for this pattern + raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") + + 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: + 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: + return Say( + self.client, + channel=self.channel_id, + thread_ts=self.thread_ts, + metadata={ + "event_type": "assistant_thread_context", + "event_payload": self.get_thread_context(), + }, + ) + + @property + def get_thread_context(self) -> GetThreadContext: + return GetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload) + + @property + def save_thread_context(self) -> SaveThreadContext: + return SaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts) diff --git a/slack_bolt/context/assistant/async_assistant_utilities.py b/slack_bolt/context/assistant/async_assistant_utilities.py new file mode 100644 index 000000000..b0f8a1fae --- /dev/null +++ b/slack_bolt/context/assistant/async_assistant_utilities.py @@ -0,0 +1,87 @@ +from typing import Optional + +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.assistant.thread_context_store.async_store import ( + AsyncAssistantThreadContextStore, +) + +from slack_bolt.context.assistant.thread_context_store.default_async_store import DefaultAsyncAssistantThreadContextStore + + +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +from ..get_thread_context.async_get_thread_context import AsyncGetThreadContext +from ..save_thread_context.async_save_thread_context import AsyncSaveThreadContext +from ..set_status.async_set_status import AsyncSetStatus +from ..set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from ..set_title.async_set_title import AsyncSetTitle + + +class AsyncAssistantUtilities: + payload: dict + client: AsyncWebClient + channel_id: str + thread_ts: str + thread_context_store: AsyncAssistantThreadContextStore + + def __init__( + self, + *, + payload: dict, + context: AsyncBoltContext, + thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + ): + self.payload = payload + self.client = context.client + self.thread_context_store = thread_context_store or DefaultAsyncAssistantThreadContextStore(context) + + if self.payload.get("assistant_thread") is not None: + # assistant_thread_started + thread = self.payload["assistant_thread"] + self.channel_id = thread["channel_id"] + self.thread_ts = thread["thread_ts"] + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self.channel_id = self.payload["channel"] + self.thread_ts = self.payload["thread_ts"] + else: + # When moving this code to Bolt internals, no need to raise an exception for this pattern + raise ValueError(f"Cannot instantiate Assistant for this event pattern ({self.payload})") + + 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: + 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( + self.client, + channel=self.channel_id, + thread_ts=self.thread_ts, + build_metadata=self._build_message_metadata, + ) + + async def _build_message_metadata(self) -> dict: + return { + "event_type": "assistant_thread_context", + "event_payload": await self.get_thread_context(), + } + + @property + def get_thread_context(self) -> AsyncGetThreadContext: + return AsyncGetThreadContext(self.thread_context_store, self.channel_id, self.thread_ts, self.payload) + + @property + def save_thread_context(self) -> AsyncSaveThreadContext: + return AsyncSaveThreadContext(self.thread_context_store, self.channel_id, self.thread_ts) diff --git a/slack_bolt/context/assistant/thread_context/__init__.py b/slack_bolt/context/assistant/thread_context/__init__.py new file mode 100644 index 000000000..bfa97feeb --- /dev/null +++ b/slack_bolt/context/assistant/thread_context/__init__.py @@ -0,0 +1,13 @@ +from typing import Optional + + +class AssistantThreadContext(dict): + enterprise_id: Optional[str] + team_id: Optional[str] + channel_id: str + + def __init__(self, payload: dict): + dict.__init__(self, **payload) + self.enterprise_id = payload.get("enterprise_id") + self.team_id = payload.get("team_id") + self.channel_id = payload["channel_id"] diff --git a/slack_bolt/context/assistant/thread_context_store/__init__.py b/slack_bolt/context/assistant/thread_context_store/__init__.py new file mode 100644 index 000000000..c761cec3a --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/__init__.py @@ -0,0 +1 @@ +# Don't add async module imports here diff --git a/slack_bolt/context/assistant/thread_context_store/async_store.py b/slack_bolt/context/assistant/thread_context_store/async_store.py new file mode 100644 index 000000000..51c0d6691 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/async_store.py @@ -0,0 +1,11 @@ +from typing import Dict, Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext + + +class AsyncAssistantThreadContextStore: + async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + raise NotImplementedError() + + async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + raise NotImplementedError() diff --git a/slack_bolt/context/assistant/thread_context_store/default_async_store.py b/slack_bolt/context/assistant/thread_context_store/default_async_store.py new file mode 100644 index 000000000..351f558d2 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/default_async_store.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional, List + +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.context.async_context import AsyncBoltContext + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.async_store import ( + AsyncAssistantThreadContextStore, +) + + +class DefaultAsyncAssistantThreadContextStore(AsyncAssistantThreadContextStore): + client: AsyncWebClient + context: AsyncBoltContext + + def __init__(self, context: AsyncBoltContext): + self.client = context.client + self.context = context + + async def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None: + await self.client.chat_update( + channel=channel_id, + ts=parent_message["ts"], + text=parent_message["text"], + blocks=parent_message["blocks"], + metadata={ + "event_type": "assistant_thread_context", + "event_payload": context, + }, + ) + + async def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + parent_message = await self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None and parent_message.get("metadata"): + if bool(parent_message["metadata"]["event_payload"]): + return AssistantThreadContext(parent_message["metadata"]["event_payload"]) + return None + + async def _retrieve_first_bot_reply(self, channel_id: str, thread_ts: str) -> Optional[dict]: + messages: List[dict] = ( + await self.client.conversations_replies( + channel=channel_id, + ts=thread_ts, + oldest=thread_ts, + include_all_metadata=True, + limit=4, # 2 should be usually enough but buffer for more robustness + ) + ).get("messages", []) + for message in messages: + if message.get("subtype") is None and message.get("user") == self.context.bot_user_id: + return message + return None diff --git a/slack_bolt/context/assistant/thread_context_store/default_store.py b/slack_bolt/context/assistant/thread_context_store/default_store.py new file mode 100644 index 000000000..9b9490737 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/default_store.py @@ -0,0 +1,50 @@ +from typing import Dict, Optional, List + +from slack_bolt.context.context import BoltContext +from slack_sdk import WebClient + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + + +class DefaultAssistantThreadContextStore(AssistantThreadContextStore): + client: WebClient + context: "BoltContext" + + def __init__(self, context: BoltContext): + self.client = context.client + self.context = context + + def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None: + self.client.chat_update( + channel=channel_id, + ts=parent_message["ts"], + text=parent_message["text"], + blocks=parent_message["blocks"], + metadata={ + "event_type": "assistant_thread_context", + "event_payload": context, + }, + ) + + def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + parent_message = self._retrieve_first_bot_reply(channel_id, thread_ts) + if parent_message is not None and parent_message.get("metadata"): + if bool(parent_message["metadata"]["event_payload"]): + return AssistantThreadContext(parent_message["metadata"]["event_payload"]) + return None + + def _retrieve_first_bot_reply(self, channel_id: str, thread_ts: str) -> Optional[dict]: + messages: List[dict] = self.client.conversations_replies( + channel=channel_id, + ts=thread_ts, + oldest=thread_ts, + include_all_metadata=True, + limit=4, # 2 should be usually enough but buffer for more robustness + ).get("messages", []) + for message in messages: + if message.get("subtype") is None and message.get("user") == self.context.bot_user_id: + return message + return None diff --git a/slack_bolt/context/assistant/thread_context_store/file/__init__.py b/slack_bolt/context/assistant/thread_context_store/file/__init__.py new file mode 100644 index 000000000..a29f3b2c0 --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/file/__init__.py @@ -0,0 +1,37 @@ +import json +from typing import Optional, Dict, Union +from pathlib import Path + +from ..store import AssistantThreadContextStore, AssistantThreadContext + + +class FileAssistantThreadContextStore(AssistantThreadContextStore): + + def __init__( + self, + base_dir: str = str(Path.home()) + "/.bolt-app-assistant-thread-contexts", + ): + self.base_dir = base_dir + self._mkdir(self.base_dir) + + def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + path = f"{self.base_dir}/{channel_id}-{thread_ts}.json" + with open(path, "w") as f: + f.write(json.dumps(context)) + + def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + path = f"{self.base_dir}/{channel_id}-{thread_ts}.json" + try: + with open(path) as f: + data = json.loads(f.read()) + if data.get("channel_id") is not None: + return AssistantThreadContext(data) + except FileNotFoundError: + pass + return None + + @staticmethod + def _mkdir(path: Union[str, Path]): + if isinstance(path, str): + path = Path(path) + path.mkdir(parents=True, exist_ok=True) diff --git a/slack_bolt/context/assistant/thread_context_store/store.py b/slack_bolt/context/assistant/thread_context_store/store.py new file mode 100644 index 000000000..2e29c55df --- /dev/null +++ b/slack_bolt/context/assistant/thread_context_store/store.py @@ -0,0 +1,11 @@ +from typing import Dict, Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext + + +class AssistantThreadContextStore: + def save(self, *, channel_id: str, thread_ts: str, context: Dict[str, str]) -> None: + raise NotImplementedError() + + def find(self, *, channel_id: str, thread_ts: str) -> Optional[AssistantThreadContext]: + raise NotImplementedError() diff --git a/slack_bolt/context/async_context.py b/slack_bolt/context/async_context.py index 58eba0850..47eb4744e 100644 --- a/slack_bolt/context/async_context.py +++ b/slack_bolt/context/async_context.py @@ -7,7 +7,12 @@ from slack_bolt.context.complete.async_complete import AsyncComplete from slack_bolt.context.fail.async_fail import AsyncFail from slack_bolt.context.respond.async_respond import AsyncRespond +from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext +from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from slack_bolt.context.set_title.async_set_title import AsyncSetTitle from slack_bolt.util.utils import create_copy @@ -105,7 +110,7 @@ async def handle_button_clicks(ack, say): Callable `say()` function """ if "say" not in self: - self["say"] = AsyncSay(client=self.client, channel=self.channel_id) + self["say"] = AsyncSay(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) return self["say"] @property @@ -181,3 +186,23 @@ async def handle_button_clicks(context): if "fail" not in self: self["fail"] = AsyncFail(client=self.client, function_execution_id=self.function_execution_id) return self["fail"] + + @property + def set_title(self) -> Optional[AsyncSetTitle]: + return self.get("set_title") + + @property + def set_status(self) -> Optional[AsyncSetStatus]: + return self.get("set_status") + + @property + def set_suggested_prompts(self) -> Optional[AsyncSetSuggestedPrompts]: + return self.get("set_suggested_prompts") + + @property + def get_thread_context(self) -> Optional[AsyncGetThreadContext]: + return self.get("get_thread_context") + + @property + def save_thread_context(self) -> Optional[AsyncSaveThreadContext]: + return self.get("save_thread_context") diff --git a/slack_bolt/context/base_context.py b/slack_bolt/context/base_context.py index 2c00d8082..843d5ef60 100644 --- a/slack_bolt/context/base_context.py +++ b/slack_bolt/context/base_context.py @@ -18,6 +18,7 @@ class BaseContext(dict): "actor_team_id", "actor_user_id", "channel_id", + "thread_ts", "response_url", "matches", "authorize_result", @@ -34,9 +35,18 @@ class BaseContext(dict): "respond", "complete", "fail", + "set_status", + "set_title", + "set_suggested_prompts", ] + # Note that these items are not copyable, so when you add new items to this list, + # you must modify ThreadListenerRunner/AsyncioListenerRunner's _build_lazy_request method to pass the values. + # Other listener runners do not require the change because they invoke a lazy listener over the network, + # meaning that the context initialization would be done again. non_copyable_standard_property_names = [ "listener_runner", + "get_thread_context", + "save_thread_context", ] standard_property_names = copyable_standard_property_names + non_copyable_standard_property_names @@ -100,6 +110,11 @@ def channel_id(self) -> Optional[str]: """The conversation ID associated with this request.""" return self.get("channel_id") + @property + def thread_ts(self) -> Optional[str]: + """The conversation thread's ID associated with this request.""" + return self.get("thread_ts") + @property def response_url(self) -> Optional[str]: """The `response_url` associated with this request.""" diff --git a/slack_bolt/context/context.py b/slack_bolt/context/context.py index c9194abb8..31edf2891 100644 --- a/slack_bolt/context/context.py +++ b/slack_bolt/context/context.py @@ -6,8 +6,13 @@ from slack_bolt.context.base_context import BaseContext from slack_bolt.context.complete import Complete from slack_bolt.context.fail import Fail +from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext from slack_bolt.context.respond import Respond +from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say +from slack_bolt.context.set_status import SetStatus +from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts +from slack_bolt.context.set_title import SetTitle from slack_bolt.util.utils import create_copy @@ -106,7 +111,7 @@ def handle_button_clicks(ack, say): Callable `say()` function """ if "say" not in self: - self["say"] = Say(client=self.client, channel=self.channel_id) + self["say"] = Say(client=self.client, channel=self.channel_id, thread_ts=self.thread_ts) return self["say"] @property @@ -182,3 +187,23 @@ def handle_button_clicks(context): if "fail" not in self: self["fail"] = Fail(client=self.client, function_execution_id=self.function_execution_id) return self["fail"] + + @property + def set_title(self) -> Optional[SetTitle]: + return self.get("set_title") + + @property + def set_status(self) -> Optional[SetStatus]: + return self.get("set_status") + + @property + def set_suggested_prompts(self) -> Optional[SetSuggestedPrompts]: + return self.get("set_suggested_prompts") + + @property + def get_thread_context(self) -> Optional[GetThreadContext]: + return self.get("get_thread_context") + + @property + def save_thread_context(self) -> Optional[SaveThreadContext]: + return self.get("save_thread_context") diff --git a/slack_bolt/context/get_thread_context/__init__.py b/slack_bolt/context/get_thread_context/__init__.py new file mode 100644 index 000000000..dd99b1b20 --- /dev/null +++ b/slack_bolt/context/get_thread_context/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .get_thread_context import GetThreadContext + +__all__ = [ + "GetThreadContext", +] diff --git a/slack_bolt/context/get_thread_context/async_get_thread_context.py b/slack_bolt/context/get_thread_context/async_get_thread_context.py new file mode 100644 index 000000000..cb8683a10 --- /dev/null +++ b/slack_bolt/context/get_thread_context/async_get_thread_context.py @@ -0,0 +1,48 @@ +from typing import Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore + + +class AsyncGetThreadContext: + thread_context_store: AsyncAssistantThreadContextStore + payload: dict + channel_id: str + thread_ts: str + + _thread_context: Optional[AssistantThreadContext] + thread_context_loaded: bool + + def __init__( + self, + thread_context_store: AsyncAssistantThreadContextStore, + channel_id: str, + thread_ts: str, + payload: dict, + ): + self.thread_context_store = thread_context_store + self.payload = payload + self.channel_id = channel_id + self.thread_ts = thread_ts + self._thread_context: Optional[AssistantThreadContext] = None + self.thread_context_loaded = False + + async def __call__(self) -> Optional[AssistantThreadContext]: + if self.thread_context_loaded is True: + return self._thread_context + + if self.payload.get("assistant_thread") is not None: + # assistant_thread_started + thread = self.payload["assistant_thread"] + self._thread_context = ( + AssistantThreadContext(thread["context"]) + if thread.get("context", {}).get("channel_id") is not None + else None + ) + # for this event, the context will never be changed + self.thread_context_loaded = True + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self._thread_context = await self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts) + + return self._thread_context diff --git a/slack_bolt/context/get_thread_context/get_thread_context.py b/slack_bolt/context/get_thread_context/get_thread_context.py new file mode 100644 index 000000000..0a77d2d9f --- /dev/null +++ b/slack_bolt/context/get_thread_context/get_thread_context.py @@ -0,0 +1,48 @@ +from typing import Optional + +from slack_bolt.context.assistant.thread_context import AssistantThreadContext +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + + +class GetThreadContext: + thread_context_store: AssistantThreadContextStore + payload: dict + channel_id: str + thread_ts: str + + _thread_context: Optional[AssistantThreadContext] + thread_context_loaded: bool + + def __init__( + self, + thread_context_store: AssistantThreadContextStore, + channel_id: str, + thread_ts: str, + payload: dict, + ): + self.thread_context_store = thread_context_store + self.payload = payload + self.channel_id = channel_id + self.thread_ts = thread_ts + self._thread_context: Optional[AssistantThreadContext] = None + self.thread_context_loaded = False + + def __call__(self) -> Optional[AssistantThreadContext]: + if self.thread_context_loaded is True: + return self._thread_context + + if self.payload.get("assistant_thread") is not None: + # assistant_thread_started + thread = self.payload["assistant_thread"] + self._thread_context = ( + AssistantThreadContext(thread["context"]) + if thread.get("context", {}).get("channel_id") is not None + else None + ) + # for this event, the context will never be changed + self.thread_context_loaded = True + elif self.payload.get("channel") is not None and self.payload.get("thread_ts") is not None: + # message event + self._thread_context = self.thread_context_store.find(channel_id=self.channel_id, thread_ts=self.thread_ts) + + return self._thread_context diff --git a/slack_bolt/context/save_thread_context/__init__.py b/slack_bolt/context/save_thread_context/__init__.py new file mode 100644 index 000000000..4980e0830 --- /dev/null +++ b/slack_bolt/context/save_thread_context/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .save_thread_context import SaveThreadContext + +__all__ = [ + "SaveThreadContext", +] diff --git a/slack_bolt/context/save_thread_context/async_save_thread_context.py b/slack_bolt/context/save_thread_context/async_save_thread_context.py new file mode 100644 index 000000000..ff79f5f64 --- /dev/null +++ b/slack_bolt/context/save_thread_context/async_save_thread_context.py @@ -0,0 +1,26 @@ +from typing import Dict + +from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore + + +class AsyncSaveThreadContext: + thread_context_store: AsyncAssistantThreadContextStore + channel_id: str + thread_ts: str + + def __init__( + self, + thread_context_store: AsyncAssistantThreadContextStore, + channel_id: str, + thread_ts: str, + ): + self.thread_context_store = thread_context_store + self.channel_id = channel_id + self.thread_ts = thread_ts + + async def __call__(self, new_context: Dict[str, str]) -> None: + await self.thread_context_store.save( + channel_id=self.channel_id, + thread_ts=self.thread_ts, + context=new_context, + ) diff --git a/slack_bolt/context/save_thread_context/save_thread_context.py b/slack_bolt/context/save_thread_context/save_thread_context.py new file mode 100644 index 000000000..4d0a13dfd --- /dev/null +++ b/slack_bolt/context/save_thread_context/save_thread_context.py @@ -0,0 +1,26 @@ +from typing import Dict + +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore + + +class SaveThreadContext: + thread_context_store: AssistantThreadContextStore + channel_id: str + thread_ts: str + + def __init__( + self, + thread_context_store: AssistantThreadContextStore, + channel_id: str, + thread_ts: str, + ): + self.thread_context_store = thread_context_store + self.channel_id = channel_id + self.thread_ts = thread_ts + + def __call__(self, new_context: Dict[str, str]) -> None: + self.thread_context_store.save( + channel_id=self.channel_id, + thread_ts=self.thread_ts, + context=new_context, + ) diff --git a/slack_bolt/context/say/async_say.py b/slack_bolt/context/say/async_say.py index 855776cbe..b771529b0 100644 --- a/slack_bolt/context/say/async_say.py +++ b/slack_bolt/context/say/async_say.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Dict, Sequence +from typing import Optional, Union, Dict, Sequence, Callable, Awaitable from slack_sdk.models.metadata import Metadata @@ -13,14 +13,20 @@ class AsyncSay: client: Optional[AsyncWebClient] channel: Optional[str] + thread_ts: Optional[str] + build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]] def __init__( self, client: Optional[AsyncWebClient], channel: Optional[str], + thread_ts: Optional[str] = None, + build_metadata: Optional[Callable[[], Awaitable[Union[Dict, Metadata]]]] = None, ): self.client = client self.channel = channel + self.thread_ts = thread_ts + self.build_metadata = build_metadata async def __call__( self, @@ -43,6 +49,8 @@ async def __call__( **kwargs, ) -> AsyncSlackResponse: if _can_say(self, channel): + if metadata is None and self.build_metadata is not None: + metadata = await self.build_metadata() text_or_whole_response: Union[str, dict] = text if isinstance(text_or_whole_response, str): text = text_or_whole_response @@ -52,7 +60,7 @@ async def __call__( blocks=blocks, attachments=attachments, as_user=as_user, - thread_ts=thread_ts, + thread_ts=thread_ts or self.thread_ts, reply_broadcast=reply_broadcast, unfurl_links=unfurl_links, unfurl_media=unfurl_media, @@ -69,6 +77,10 @@ async def __call__( message: dict = create_copy(text_or_whole_response) if "channel" not in message: message["channel"] = channel or self.channel + if "thread_ts" not in message: + message["thread_ts"] = thread_ts or self.thread_ts + if "metadata" not in message: + message["metadata"] = metadata return await self.client.chat_postMessage(**message) # type: ignore[union-attr] else: raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})") diff --git a/slack_bolt/context/say/say.py b/slack_bolt/context/say/say.py index f6ecd337c..6c0127a62 100644 --- a/slack_bolt/context/say/say.py +++ b/slack_bolt/context/say/say.py @@ -13,14 +13,20 @@ class Say: client: Optional[WebClient] channel: Optional[str] + thread_ts: Optional[str] + metadata: Optional[Union[Dict, Metadata]] def __init__( self, client: Optional[WebClient], channel: Optional[str], + thread_ts: Optional[str] = None, + metadata: Optional[Union[Dict, Metadata]] = None, ): self.client = client self.channel = channel + self.thread_ts = thread_ts + self.metadata = metadata def __call__( self, @@ -52,7 +58,7 @@ def __call__( blocks=blocks, attachments=attachments, as_user=as_user, - thread_ts=thread_ts, + thread_ts=thread_ts or self.thread_ts, reply_broadcast=reply_broadcast, unfurl_links=unfurl_links, unfurl_media=unfurl_media, @@ -62,13 +68,17 @@ def __call__( mrkdwn=mrkdwn, link_names=link_names, parse=parse, - metadata=metadata, + metadata=metadata or self.metadata, **kwargs, ) elif isinstance(text_or_whole_response, dict): message: dict = create_copy(text_or_whole_response) if "channel" not in message: message["channel"] = channel or self.channel + if "thread_ts" not in message: + message["thread_ts"] = thread_ts or self.thread_ts + if "metadata" not in message: + message["metadata"] = metadata or self.metadata return self.client.chat_postMessage(**message) # type: ignore[union-attr] else: raise ValueError(f"The arg is unexpected type ({type(text_or_whole_response)})") diff --git a/slack_bolt/context/set_status/__init__.py b/slack_bolt/context/set_status/__init__.py new file mode 100644 index 000000000..c12f9658b --- /dev/null +++ b/slack_bolt/context/set_status/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .set_status import SetStatus + +__all__ = [ + "SetStatus", +] diff --git a/slack_bolt/context/set_status/async_set_status.py b/slack_bolt/context/set_status/async_set_status.py new file mode 100644 index 000000000..926ec6de8 --- /dev/null +++ b/slack_bolt/context/set_status/async_set_status.py @@ -0,0 +1,25 @@ +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncSetStatus: + client: AsyncWebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: AsyncWebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + async def __call__(self, status: str) -> AsyncSlackResponse: + return await self.client.assistant_threads_setStatus( + status=status, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + ) diff --git a/slack_bolt/context/set_status/set_status.py b/slack_bolt/context/set_status/set_status.py new file mode 100644 index 000000000..8df0d49a7 --- /dev/null +++ b/slack_bolt/context/set_status/set_status.py @@ -0,0 +1,25 @@ +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class SetStatus: + client: WebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: WebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + def __call__(self, status: str) -> SlackResponse: + return self.client.assistant_threads_setStatus( + status=status, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + ) diff --git a/slack_bolt/context/set_suggested_prompts/__init__.py b/slack_bolt/context/set_suggested_prompts/__init__.py new file mode 100644 index 000000000..e5efd26c7 --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .set_suggested_prompts import SetSuggestedPrompts + +__all__ = [ + "SetSuggestedPrompts", +] 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 new file mode 100644 index 000000000..76f827732 --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/async_set_suggested_prompts.py @@ -0,0 +1,34 @@ +from typing import List, Dict, Union + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncSetSuggestedPrompts: + client: AsyncWebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: AsyncWebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + async def __call__(self, prompts: List[Union[str, Dict[str, str]]]) -> AsyncSlackResponse: + prompts_arg: List[Dict[str, str]] = [] + for prompt in prompts: + if isinstance(prompt, str): + prompts_arg.append({"title": prompt, "message": prompt}) + else: + prompts_arg.append(prompt) + + return await self.client.assistant_threads_setSuggestedPrompts( + channel_id=self.channel_id, + thread_ts=self.thread_ts, + prompts=prompts_arg, + ) diff --git a/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py new file mode 100644 index 000000000..3714f4830 --- /dev/null +++ b/slack_bolt/context/set_suggested_prompts/set_suggested_prompts.py @@ -0,0 +1,34 @@ +from typing import List, Dict, Union + +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class SetSuggestedPrompts: + client: WebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: WebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + def __call__(self, prompts: List[Union[str, Dict[str, str]]]) -> SlackResponse: + prompts_arg: List[Dict[str, str]] = [] + for prompt in prompts: + if isinstance(prompt, str): + prompts_arg.append({"title": prompt, "message": prompt}) + else: + prompts_arg.append(prompt) + + return self.client.assistant_threads_setSuggestedPrompts( + channel_id=self.channel_id, + thread_ts=self.thread_ts, + prompts=prompts_arg, + ) diff --git a/slack_bolt/context/set_title/__init__.py b/slack_bolt/context/set_title/__init__.py new file mode 100644 index 000000000..e799e88ae --- /dev/null +++ b/slack_bolt/context/set_title/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .set_title import SetTitle + +__all__ = [ + "SetTitle", +] diff --git a/slack_bolt/context/set_title/async_set_title.py b/slack_bolt/context/set_title/async_set_title.py new file mode 100644 index 000000000..ea6bfc98a --- /dev/null +++ b/slack_bolt/context/set_title/async_set_title.py @@ -0,0 +1,25 @@ +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web.async_slack_response import AsyncSlackResponse + + +class AsyncSetTitle: + client: AsyncWebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: AsyncWebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + async def __call__(self, title: str) -> AsyncSlackResponse: + return await self.client.assistant_threads_setTitle( + title=title, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + ) diff --git a/slack_bolt/context/set_title/set_title.py b/slack_bolt/context/set_title/set_title.py new file mode 100644 index 000000000..5670c6b73 --- /dev/null +++ b/slack_bolt/context/set_title/set_title.py @@ -0,0 +1,25 @@ +from slack_sdk import WebClient +from slack_sdk.web import SlackResponse + + +class SetTitle: + client: WebClient + channel_id: str + thread_ts: str + + def __init__( + self, + client: WebClient, + channel_id: str, + thread_ts: str, + ): + self.client = client + self.channel_id = channel_id + self.thread_ts = thread_ts + + def __call__(self, title: str) -> SlackResponse: + return self.client.assistant_threads_setTitle( + title=title, + channel_id=self.channel_id, + thread_ts=self.thread_ts, + ) diff --git a/slack_bolt/kwargs_injection/args.py b/slack_bolt/kwargs_injection/args.py index 68e64a8e8..1a0ec3ca8 100644 --- a/slack_bolt/kwargs_injection/args.py +++ b/slack_bolt/kwargs_injection/args.py @@ -6,8 +6,13 @@ from slack_bolt.context.ack import Ack from slack_bolt.context.complete import Complete from slack_bolt.context.fail import Fail +from slack_bolt.context.get_thread_context.get_thread_context import GetThreadContext from slack_bolt.context.respond import Respond +from slack_bolt.context.save_thread_context import SaveThreadContext from slack_bolt.context.say import Say +from slack_bolt.context.set_status import SetStatus +from slack_bolt.context.set_suggested_prompts import SetSuggestedPrompts +from slack_bolt.context.set_title import SetTitle from slack_bolt.request import BoltRequest from slack_bolt.response import BoltResponse from slack_sdk import WebClient @@ -87,6 +92,16 @@ def handle_buttons(args): """`complete()` utility function, signals a successful completion of the custom function""" fail: Fail """`fail()` utility function, signal that the custom function failed to complete""" + set_status: Optional[SetStatus] + """`set_status()` utility function for AI Agents & Assistants""" + set_title: Optional[SetTitle] + """`set_title()` utility function for AI Agents & Assistants""" + set_suggested_prompts: Optional[SetSuggestedPrompts] + """`set_suggested_prompts()` utility function for AI Agents & Assistants""" + get_thread_context: Optional[GetThreadContext] + """`get_thread_context()` utility function for AI Agents & Assistants""" + save_thread_context: Optional[SaveThreadContext] + """`save_thread_context()` utility function for AI Agents & Assistants""" # middleware next: Callable[[], None] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -115,6 +130,11 @@ def __init__( respond: Respond, complete: Complete, fail: Fail, + set_status: Optional[SetStatus] = None, + set_title: Optional[SetTitle] = None, + set_suggested_prompts: Optional[SetSuggestedPrompts] = None, + get_thread_context: Optional[GetThreadContext] = None, + save_thread_context: Optional[SaveThreadContext] = None, # As this method is not supposed to be invoked by bolt-python users, # the naming conflict with the built-in one affects # only the internals of this method @@ -142,5 +162,12 @@ def __init__( self.respond: Respond = respond self.complete: Complete = complete self.fail: Fail = fail + + self.set_status = set_status + self.set_title = set_title + self.set_suggested_prompts = set_suggested_prompts + self.get_thread_context = get_thread_context + self.save_thread_context = save_thread_context + self.next: Callable[[], None] = next self.next_: Callable[[], None] = next diff --git a/slack_bolt/kwargs_injection/async_args.py b/slack_bolt/kwargs_injection/async_args.py index 1601a552a..4953f2167 100644 --- a/slack_bolt/kwargs_injection/async_args.py +++ b/slack_bolt/kwargs_injection/async_args.py @@ -6,7 +6,12 @@ from slack_bolt.context.complete.async_complete import AsyncComplete from slack_bolt.context.fail.async_fail import AsyncFail from slack_bolt.context.respond.async_respond import AsyncRespond +from slack_bolt.context.get_thread_context.async_get_thread_context import AsyncGetThreadContext +from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext from slack_bolt.context.say.async_say import AsyncSay +from slack_bolt.context.set_status.async_set_status import AsyncSetStatus +from slack_bolt.context.set_suggested_prompts.async_set_suggested_prompts import AsyncSetSuggestedPrompts +from slack_bolt.context.set_title.async_set_title import AsyncSetTitle from slack_bolt.request.async_request import AsyncBoltRequest from slack_bolt.response import BoltResponse from slack_sdk.web.async_client import AsyncWebClient @@ -86,6 +91,16 @@ async def handle_buttons(args): """`complete()` utility function, signals a successful completion of the custom function""" fail: AsyncFail """`fail()` utility function, signal that the custom function failed to complete""" + set_status: Optional[AsyncSetStatus] + """`set_status()` utility function for AI Agents & Assistants""" + set_title: Optional[AsyncSetTitle] + """`set_title()` utility function for AI Agents & Assistants""" + set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] + """`set_suggested_prompts()` utility function for AI Agents & Assistants""" + get_thread_context: Optional[AsyncGetThreadContext] + """`get_thread_context()` utility function for AI Agents & Assistants""" + save_thread_context: Optional[AsyncSaveThreadContext] + """`save_thread_context()` utility function for AI Agents & Assistants""" # middleware next: Callable[[], Awaitable[None]] """`next()` utility function, which tells the middleware chain that it can continue with the next one""" @@ -114,6 +129,11 @@ def __init__( respond: AsyncRespond, complete: AsyncComplete, fail: AsyncFail, + set_status: Optional[AsyncSetStatus] = None, + set_title: Optional[AsyncSetTitle] = None, + set_suggested_prompts: Optional[AsyncSetSuggestedPrompts] = None, + get_thread_context: Optional[AsyncGetThreadContext] = None, + save_thread_context: Optional[AsyncSaveThreadContext] = None, next: Callable[[], Awaitable[None]], **kwargs, # noqa ): @@ -138,5 +158,12 @@ def __init__( self.respond: AsyncRespond = respond self.complete: AsyncComplete = complete self.fail: AsyncFail = fail + + self.set_status = set_status + self.set_title = set_title + self.set_suggested_prompts = set_suggested_prompts + self.get_thread_context = get_thread_context + self.save_thread_context = save_thread_context + self.next: Callable[[], Awaitable[None]] = next self.next_: Callable[[], Awaitable[None]] = next diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index a31b079db..c8870c3cc 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -53,6 +53,11 @@ def build_async_required_kwargs( "respond": request.context.respond, "complete": request.context.complete, "fail": request.context.fail, + "set_status": request.context.set_status, + "set_title": request.context.set_title, + "set_suggested_prompts": request.context.set_suggested_prompts, + "get_thread_context": request.context.get_thread_context, + "save_thread_context": request.context.save_thread_context, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index 30b5d21e2..c1909c67a 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -53,6 +53,10 @@ def build_required_kwargs( "respond": request.context.respond, "complete": request.context.complete, "fail": request.context.fail, + "set_status": request.context.set_status, + "set_title": request.context.set_title, + "set_suggested_prompts": request.context.set_suggested_prompts, + "save_thread_context": request.context.save_thread_context, # middleware "next": next_func, "next_": next_func, # for the middleware using Python's built-in `next()` function diff --git a/slack_bolt/listener/asyncio_runner.py b/slack_bolt/listener/asyncio_runner.py index 01e8641ed..56dc29cc1 100644 --- a/slack_bolt/listener/asyncio_runner.py +++ b/slack_bolt/listener/asyncio_runner.py @@ -179,6 +179,10 @@ def _build_lazy_request(self, request: AsyncBoltRequest, lazy_func_name: str) -> copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name copied_request.context["listener_runner"] = self + if request.context.get_thread_context is not None: + copied_request.context["get_thread_context"] = request.context.get_thread_context + if request.context.save_thread_context is not None: + copied_request.context["save_thread_context"] = request.context.save_thread_context return copied_request def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None: diff --git a/slack_bolt/listener/thread_runner.py b/slack_bolt/listener/thread_runner.py index c2d87b3d5..0b79c6ffd 100644 --- a/slack_bolt/listener/thread_runner.py +++ b/slack_bolt/listener/thread_runner.py @@ -189,7 +189,12 @@ def _build_lazy_request(self, request: BoltRequest, lazy_func_name: str) -> Bolt copied_request: BoltRequest = create_copy(request.to_copyable()) copied_request.lazy_only = True copied_request.lazy_function_name = lazy_func_name + # These are not copyable objects, so manually set for a different thread copied_request.context["listener_runner"] = self + if request.context.get_thread_context is not None: + copied_request.context["get_thread_context"] = request.context.get_thread_context + if request.context.save_thread_context is not None: + copied_request.context["save_thread_context"] = request.context.save_thread_context return copied_request def _debug_log_completion(self, starting_time: float, response: BoltResponse) -> None: diff --git a/slack_bolt/middleware/__init__.py b/slack_bolt/middleware/__init__.py index ee962146f..0e4044f99 100644 --- a/slack_bolt/middleware/__init__.py +++ b/slack_bolt/middleware/__init__.py @@ -26,6 +26,7 @@ IgnoringSelfEvents, UrlVerification, AttachingFunctionToken, + # Assistant, # to avoid circular imports ] for cls in builtin_middleware_classes: Middleware.register(cls) # type: ignore[arg-type] diff --git a/slack_bolt/middleware/assistant/__init__.py b/slack_bolt/middleware/assistant/__init__.py new file mode 100644 index 000000000..4487394ab --- /dev/null +++ b/slack_bolt/middleware/assistant/__init__.py @@ -0,0 +1,6 @@ +# Don't add async module imports here +from .assistant import Assistant + +__all__ = [ + "Assistant", +] diff --git a/slack_bolt/middleware/assistant/assistant.py b/slack_bolt/middleware/assistant/assistant.py new file mode 100644 index 000000000..beac71bca --- /dev/null +++ b/slack_bolt/middleware/assistant/assistant.py @@ -0,0 +1,291 @@ +import logging +from functools import wraps +from logging import Logger +from typing import List, Optional, Union, Callable + +from slack_bolt.context.save_thread_context import SaveThreadContext +from slack_bolt.context.assistant.thread_context_store.store import AssistantThreadContextStore +from slack_bolt.listener_matcher.builtins import build_listener_matcher + +from slack_bolt.request.request import BoltRequest +from slack_bolt.response.response import BoltResponse +from slack_bolt.listener_matcher import CustomListenerMatcher +from slack_bolt.error import BoltError +from slack_bolt.listener.custom_listener import CustomListener +from slack_bolt.listener import Listener +from slack_bolt.listener.thread_runner import ThreadListenerRunner +from slack_bolt.middleware import Middleware +from slack_bolt.listener_matcher import ListenerMatcher +from slack_bolt.request.payload_utils import ( + is_assistant_thread_started_event, + is_user_message_event_in_assistant_thread, + is_assistant_thread_context_changed_event, + is_other_message_sub_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, +) +from slack_bolt.util.utils import is_used_without_argument + + +class Assistant(Middleware): + _thread_started_listeners: Optional[List[Listener]] + _thread_context_changed_listeners: Optional[List[Listener]] + _user_message_listeners: Optional[List[Listener]] + _bot_message_listeners: Optional[List[Listener]] + + thread_context_store: Optional[AssistantThreadContextStore] + base_logger: Optional[logging.Logger] + + def __init__( + self, + *, + app_name: str = "assistant", + thread_context_store: Optional[AssistantThreadContextStore] = None, + logger: Optional[logging.Logger] = None, + ): + self.app_name = app_name + self.thread_context_store = thread_context_store + self.base_logger = logger + + self._thread_started_listeners = None + self._thread_context_changed_listeners = None + self._user_message_listeners = None + self._bot_message_listeners = None + + def thread_started( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_started_listeners is None: + self._thread_started_listeners = [] + all_matchers = self._merge_matchers(is_assistant_thread_started_event, matchers) + if is_used_without_argument(args): + func = args[0] + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def user_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._user_message_listeners is None: + self._user_message_listeners = [] + all_matchers = self._merge_matchers(is_user_message_event_in_assistant_thread, matchers) + if is_used_without_argument(args): + func = args[0] + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def bot_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._bot_message_listeners is None: + self._bot_message_listeners = [] + all_matchers = self._merge_matchers(is_bot_message_event_in_assistant_thread, matchers) + if is_used_without_argument(args): + func = args[0] + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def thread_context_changed( + self, + *args, + matchers: Optional[Union[Callable[..., bool], ListenerMatcher]] = None, + middleware: Optional[Union[Callable, Middleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_context_changed_listeners is None: + self._thread_context_changed_listeners = [] + all_matchers = self._merge_matchers(is_assistant_thread_context_changed_event, matchers) + if is_used_without_argument(args): + func = args[0] + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def _merge_matchers( + self, + primary_matcher: Callable[..., bool], + custom_matchers: Optional[Union[Callable[..., bool], ListenerMatcher]], + ): + return [CustomListenerMatcher(app_name=self.app_name, func=primary_matcher)] + ( + custom_matchers or [] + ) # type:ignore[operator] + + @staticmethod + def default_thread_context_changed(save_thread_context: SaveThreadContext, payload: dict): + save_thread_context(payload["assistant_thread"]["context"]) + + def process( # type:ignore[return] + self, *, req: BoltRequest, resp: BoltResponse, next: Callable[[], BoltResponse] + ) -> Optional[BoltResponse]: + if self._thread_context_changed_listeners is None: + self.thread_context_changed(self.default_thread_context_changed) + + listener_runner: ThreadListenerRunner = req.context.listener_runner + for listeners in [ + self._thread_started_listeners, + self._thread_context_changed_listeners, + self._user_message_listeners, + self._bot_message_listeners, + ]: + if listeners is not None: + for listener in listeners: + if listener.matches(req=req, resp=resp): + return listener_runner.run( + request=req, + response=resp, + listener_name="assistant_listener", + listener=listener, + ) + if is_other_message_sub_event_in_assistant_thread(req.body): + # message_changed, message_deleted, etc. + return req.context.ack() + + next() + + def build_listener( + self, + listener_or_functions: Union[Listener, Callable, List[Callable]], + matchers: Optional[List[Union[ListenerMatcher, Callable[..., bool]]]] = None, + middleware: Optional[List[Middleware]] = None, + base_logger: Optional[Logger] = None, + ) -> Listener: + if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] + listener_or_functions = [listener_or_functions] # type:ignore[list-item] + + if isinstance(listener_or_functions, Listener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + middleware = middleware if middleware else [] + functions = listener_or_functions + ack_function = functions.pop(0) + + matchers = matchers if matchers else [] + listener_matchers: List[ListenerMatcher] = [] + for matcher in matchers: + if isinstance(matcher, ListenerMatcher): + listener_matchers.append(matcher) + elif isinstance(matcher, Callable): # type:ignore[arg-type] + listener_matchers.append( + build_listener_matcher( + func=matcher, + asyncio=False, + base_logger=base_logger, + ) + ) + return CustomListener( + app_name=self.app_name, + matchers=listener_matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, + auto_acknowledgement=True, + base_logger=base_logger or self.base_logger, + ) + else: + raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected") diff --git a/slack_bolt/middleware/assistant/async_assistant.py b/slack_bolt/middleware/assistant/async_assistant.py new file mode 100644 index 000000000..2fdd828d7 --- /dev/null +++ b/slack_bolt/middleware/assistant/async_assistant.py @@ -0,0 +1,320 @@ +import logging +from functools import wraps +from logging import Logger +from typing import List, Optional, Union, Callable, Awaitable + +from slack_bolt.context.save_thread_context.async_save_thread_context import AsyncSaveThreadContext +from slack_bolt.context.assistant.thread_context_store.async_store import AsyncAssistantThreadContextStore + +from slack_bolt.listener.asyncio_runner import AsyncioListenerRunner +from slack_bolt.listener_matcher.builtins import build_listener_matcher +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse +from slack_bolt.error import BoltError +from slack_bolt.listener.async_listener import AsyncListener, AsyncCustomListener +from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.listener_matcher.async_listener_matcher import AsyncListenerMatcher +from slack_bolt.request.payload_utils import ( + is_assistant_thread_started_event, + is_user_message_event_in_assistant_thread, + is_assistant_thread_context_changed_event, + is_other_message_sub_event_in_assistant_thread, + is_bot_message_event_in_assistant_thread, +) +from slack_bolt.util.utils import is_used_without_argument + + +class AsyncAssistant(AsyncMiddleware): + _thread_started_listeners: Optional[List[AsyncListener]] + _user_message_listeners: Optional[List[AsyncListener]] + _bot_message_listeners: Optional[List[AsyncListener]] + _thread_context_changed_listeners: Optional[List[AsyncListener]] + + thread_context_store: Optional[AsyncAssistantThreadContextStore] + base_logger: Optional[logging.Logger] + + def __init__( + self, + *, + app_name: str = "assistant", + thread_context_store: Optional[AsyncAssistantThreadContextStore] = None, + logger: Optional[logging.Logger] = None, + ): + self.app_name = app_name + self.thread_context_store = thread_context_store + self.base_logger = logger + + self._thread_started_listeners = None + self._thread_context_changed_listeners = None + self._user_message_listeners = None + self._bot_message_listeners = None + + def thread_started( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_started_listeners is None: + self._thread_started_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_assistant_thread_started_event, + asyncio=True, + base_logger=self.base_logger, + ), # type:ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_started_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def user_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._user_message_listeners is None: + self._user_message_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_user_message_event_in_assistant_thread, + asyncio=True, + base_logger=self.base_logger, + ), # type:ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._user_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def bot_message( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._bot_message_listeners is None: + self._bot_message_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_bot_message_event_in_assistant_thread, + asyncio=True, + base_logger=self.base_logger, + ), # type:ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._bot_message_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + def thread_context_changed( + self, + *args, + matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]] = None, + middleware: Optional[Union[Callable, AsyncMiddleware]] = None, + lazy: Optional[List[Callable[..., None]]] = None, + ): + if self._thread_context_changed_listeners is None: + self._thread_context_changed_listeners = [] + all_matchers = self._merge_matchers( + build_listener_matcher( + func=is_assistant_thread_context_changed_event, + asyncio=True, + base_logger=self.base_logger, + ), # type:ignore[arg-type] + matchers, + ) + if is_used_without_argument(args): + func = args[0] + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=func, + matchers=all_matchers, + middleware=middleware, # type:ignore[arg-type] + ) + ) + return func + + def _inner(func): + functions = [func] + (lazy if lazy is not None else []) + self._thread_context_changed_listeners.append( + self.build_listener( + listener_or_functions=functions, + matchers=all_matchers, + middleware=middleware, + ) + ) + + @wraps(func) + def _wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _wrapper + + return _inner + + @staticmethod + def _merge_matchers( + primary_matcher: Union[Callable[..., bool], AsyncListenerMatcher], + custom_matchers: Optional[Union[Callable[..., bool], AsyncListenerMatcher]], + ): + return [primary_matcher] + (custom_matchers or []) # type:ignore[operator] + + @staticmethod + async def default_thread_context_changed(save_thread_context: AsyncSaveThreadContext, payload: dict): + new_context: dict = payload["assistant_thread"]["context"] + await save_thread_context(new_context) + + async def async_process( # type:ignore[return] + self, + *, + req: AsyncBoltRequest, + resp: BoltResponse, + next: Callable[[], Awaitable[BoltResponse]], + ) -> Optional[BoltResponse]: + if self._thread_context_changed_listeners is None: + self.thread_context_changed(self.default_thread_context_changed) + + listener_runner: AsyncioListenerRunner = req.context.listener_runner + for listeners in [ + self._thread_started_listeners, + self._thread_context_changed_listeners, + self._user_message_listeners, + self._bot_message_listeners, + ]: + if listeners is not None: + for listener in listeners: + if listener is not None and await listener.async_matches(req=req, resp=resp): + return await listener_runner.run( + request=req, + response=resp, + listener_name="assistant_listener", + listener=listener, + ) + if is_other_message_sub_event_in_assistant_thread(req.body): + # message_changed, message_deleted, etc. + return await req.context.ack() + + await next() + + def build_listener( + self, + listener_or_functions: Union[AsyncListener, Callable, List[Callable]], + matchers: Optional[List[Union[AsyncListenerMatcher, Callable[..., Awaitable[bool]]]]] = None, + middleware: Optional[List[AsyncMiddleware]] = None, + base_logger: Optional[Logger] = None, + ) -> AsyncListener: + if isinstance(listener_or_functions, Callable): # type:ignore[arg-type] + listener_or_functions = [listener_or_functions] # type:ignore[list-item] + + if isinstance(listener_or_functions, AsyncListener): + return listener_or_functions + elif isinstance(listener_or_functions, list): + middleware = middleware if middleware else [] + functions = listener_or_functions + ack_function = functions.pop(0) + + matchers = matchers if matchers else [] + listener_matchers: List[AsyncListenerMatcher] = [] + for matcher in matchers: + if isinstance(matcher, AsyncListenerMatcher): + listener_matchers.append(matcher) + else: + listener_matchers.append( + build_listener_matcher( + func=matcher, # type:ignore[arg-type] + asyncio=True, + base_logger=base_logger, + ) + ) + return AsyncCustomListener( + app_name=self.app_name, + matchers=listener_matchers, + middleware=middleware, + ack_function=ack_function, + lazy_functions=functions, + auto_acknowledgement=True, + base_logger=base_logger or self.base_logger, + ) + else: + raise BoltError(f"Invalid listener: {type(listener_or_functions)} detected") diff --git a/slack_bolt/middleware/authorization/single_team_authorization.py b/slack_bolt/middleware/authorization/single_team_authorization.py index 80a864b4e..c2bc1488c 100644 --- a/slack_bolt/middleware/authorization/single_team_authorization.py +++ b/slack_bolt/middleware/authorization/single_team_authorization.py @@ -47,6 +47,7 @@ def process( # only the internals of this method next: Callable[[], BoltResponse], ) -> BoltResponse: + if _is_no_auth_required(req): return next() diff --git a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py index ca2d7fed3..11a3f40ee 100644 --- a/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/async_ignoring_self_events.py @@ -4,6 +4,7 @@ from slack_bolt.response import BoltResponse from .ignoring_self_events import IgnoringSelfEvents from slack_bolt.middleware.async_middleware import AsyncMiddleware +from slack_bolt.request.payload_utils import is_bot_message_event_in_assistant_thread class AsyncIgnoringSelfEvents(IgnoringSelfEvents, AsyncMiddleware): @@ -18,6 +19,11 @@ async def async_process( # message events can have $.event.bot_id while it does not have its user_id bot_id = req.body.get("event", {}).get("bot_id") if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body): # type: ignore[arg-type] + if self.ignoring_self_assistant_message_events_enabled is False: + if is_bot_message_event_in_assistant_thread(req.body): + # Assistant#bot_message handler acknowledges this pattern + return await next() + self._debug_log(req.body) return await req.context.ack() else: diff --git a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py index 0870991e3..3380636f0 100644 --- a/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py +++ b/slack_bolt/middleware/ignoring_self_events/ignoring_self_events.py @@ -4,14 +4,20 @@ from slack_bolt.authorization import AuthorizeResult from slack_bolt.logger import get_bolt_logger from slack_bolt.request import BoltRequest +from slack_bolt.request.payload_utils import is_bot_message_event_in_assistant_thread from slack_bolt.response import BoltResponse from slack_bolt.middleware.middleware import Middleware class IgnoringSelfEvents(Middleware): - def __init__(self, base_logger: Optional[logging.Logger] = None): + def __init__( + self, + base_logger: Optional[logging.Logger] = None, + ignoring_self_assistant_message_events_enabled: bool = True, + ): """Ignores the events generated by this bot user itself.""" self.logger = get_bolt_logger(IgnoringSelfEvents, base_logger=base_logger) + self.ignoring_self_assistant_message_events_enabled = ignoring_self_assistant_message_events_enabled def process( self, @@ -24,6 +30,11 @@ def process( # message events can have $.event.bot_id while it does not have its user_id bot_id = req.body.get("event", {}).get("bot_id") if self._is_self_event(auth_result, req.context.user_id, bot_id, req.body): # type: ignore[arg-type] + if self.ignoring_self_assistant_message_events_enabled is False: + if is_bot_message_event_in_assistant_thread(req.body): + # Assistant#bot_message handler acknowledges this pattern + return next() + self._debug_log(req.body) return req.context.ack() else: diff --git a/slack_bolt/request/async_internals.py b/slack_bolt/request/async_internals.py index f1f00dece..ea94739e8 100644 --- a/slack_bolt/request/async_internals.py +++ b/slack_bolt/request/async_internals.py @@ -14,6 +14,7 @@ extract_actor_enterprise_id, extract_actor_team_id, extract_actor_user_id, + extract_thread_ts, ) @@ -44,6 +45,9 @@ def build_async_context( channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id + thread_ts = extract_thread_ts(body) + if thread_ts: + context["thread_ts"] = thread_ts function_execution_id = extract_function_execution_id(body) if function_execution_id: context["function_execution_id"] = function_execution_id diff --git a/slack_bolt/request/internals.py b/slack_bolt/request/internals.py index bee746bf2..b04f336bf 100644 --- a/slack_bolt/request/internals.py +++ b/slack_bolt/request/internals.py @@ -3,6 +3,7 @@ from urllib.parse import parse_qsl, parse_qs from slack_bolt.context import BoltContext +from slack_bolt.request.payload_utils import is_assistant_event def parse_query(query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[str]]]]) -> Dict[str, Sequence[str]]: @@ -207,6 +208,31 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]: if payload.get("item") is not None: # reaction_added: body["event"]["item"] return extract_channel_id(payload["item"]) + if payload.get("assistant_thread") is not None: + # assistant_thread_started + return extract_channel_id(payload["assistant_thread"]) + return None + + +def extract_thread_ts(payload: Dict[str, Any]) -> Optional[str]: + # This utility initially supports only the use cases for AI assistants, but it may be fine to add more patterns. + # That said, note that thread_ts is always required for assistant threads, but it's not for channels. + # Thus, blindly setting this thread_ts to say utility can break existing apps' behaviors. + if is_assistant_event(payload): + event = payload["event"] + if event.get("assistant_thread") is not None: + # assistant_thread_started, assistant_thread_context_changed + return event["assistant_thread"]["thread_ts"] + elif event.get("channel") is not None: + if event.get("thread_ts") is not None: + # message in an assistant thread + return event["thread_ts"] + elif event.get("message", {}).get("thread_ts") is not None: + # message_changed + return event["message"]["thread_ts"] + elif event.get("previous_message", {}).get("thread_ts") is not None: + # message_deleted + return event["previous_message"]["thread_ts"] return None @@ -260,6 +286,9 @@ def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext: channel_id = extract_channel_id(body) if channel_id: context["channel_id"] = channel_id + thread_ts = extract_thread_ts(body) + if thread_ts: + context["thread_ts"] = thread_ts function_execution_id = extract_function_execution_id(body) if function_execution_id is not None: context["function_execution_id"] = function_execution_id diff --git a/slack_bolt/request/payload_utils.py b/slack_bolt/request/payload_utils.py index 9e9ace78f..c1016c65d 100644 --- a/slack_bolt/request/payload_utils.py +++ b/slack_bolt/request/payload_utils.py @@ -32,6 +32,73 @@ 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_assistant_event(body: Dict[str, Any]) -> bool: + return is_event(body) and ( + is_assistant_thread_started_event(body) + or is_assistant_thread_context_changed_event(body) + or is_user_message_event_in_assistant_thread(body) + or is_bot_message_event_in_assistant_thread(body) + ) + + +def is_assistant_thread_started_event(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "assistant_thread_started" + return False + + +def is_assistant_thread_context_changed_event(body: Dict[str, Any]) -> bool: + if is_event(body): + return body["event"]["type"] == "assistant_thread_context_changed" + 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" + 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 + ) + return False + + +def is_bot_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: + if is_event(body): + return ( + is_message_event_in_assistant_thread(body) + and body["event"].get("subtype") is None + and body["event"].get("thread_ts") is not None + and body["event"].get("bot_id") is not None + ) + return False + + +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")) + ) + ) + return False + + +def _is_other_message_sub_event(message: Optional[Dict[str, Any]]) -> bool: + return message is not None and (message.get("subtype") == "assistant_app_thread" or message.get("thread_ts") is not None) + + # ------------------- # Slash Commands # ------------------- diff --git a/slack_bolt/util/utils.py b/slack_bolt/util/utils.py index 738b6bf03..0abdcfcbd 100644 --- a/slack_bolt/util/utils.py +++ b/slack_bolt/util/utils.py @@ -94,3 +94,15 @@ def is_callable_coroutine(func: Optional[Any]) -> bool: return func is not None and ( inspect.iscoroutinefunction(func) or (hasattr(func, "__call__") and inspect.iscoroutinefunction(func.__call__)) ) + + +def is_used_without_argument(args) -> bool: + """Tests if a decorator invocation is without () or (args). + + Args: + args: arguments + + Returns: + True if it's an invocation without args + """ + return len(args) == 1 diff --git a/tests/scenario_tests/test_events_assistant.py b/tests/scenario_tests/test_events_assistant.py new file mode 100644 index 000000000..ed3026d12 --- /dev/null +++ b/tests/scenario_tests/test_events_assistant.py @@ -0,0 +1,259 @@ +from time import sleep + +from slack_sdk.web import WebClient + +from slack_bolt import App, BoltRequest, Assistant, Say, SetSuggestedPrompts, SetStatus, BoltContext +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestEventsAssistant: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = WebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_assistant_threads(self): + app = App(client=self.web_client) + assistant = Assistant() + + state = {"called": False} + + def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @assistant.thread_started + def start_thread(say: Say, set_suggested_prompts: SetSuggestedPrompts, context: BoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + say("Hi, how can I help you today?") + set_suggested_prompts(prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}]) + state["called"] = True + + @assistant.thread_context_changed + def handle_thread_context_changed(context: BoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + state["called"] = True + + @assistant.user_message + def handle_user_message(say: Say, set_status: SetStatus, context: BoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + try: + set_status("is typing...") + say("Here you are!") + state["called"] = True + except Exception as e: + say(f"Oops, something went wrong (error: {e}") + + app.assistant(assistant) + + request = BoltRequest(body=thread_started_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + request = BoltRequest(body=thread_context_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + request = BoltRequest(body=user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + assert_target_called() + + request = BoltRequest(body=message_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 200 + + request = BoltRequest(body=channel_user_message_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 404 + + request = BoltRequest(body=channel_message_changed_event_body, mode="socket_mode") + response = app.dispatch(request) + assert response.status == 404 + + +def build_payload(event: dict) -> dict: + return { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": event, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + +thread_started_event_body = build_payload( + { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C222", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + +thread_context_changed_event_body = build_payload( + { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C333", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + + +user_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "When Slack was released?", + "team": "T111", + "user_team": "T111", + "source_team": "T222", + "user_profile": {}, + "thread_ts": "1726133698.626339", + "parent_user_id": "W222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + + +message_changed_event_body = build_payload( + { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "assistant_app_thread": {"title": "When Slack was released?", "title_blocks": [], "artifacts": []}, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "D111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "im", + } +) + +channel_user_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "When Slack was released?", + "team": "T111", + "user_team": "T111", + "source_team": "T222", + "user_profile": {}, + "thread_ts": "1726133698.626339", + "parent_user_id": "W222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "channel", + } +) + +channel_message_changed_event_body = build_payload( + { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "D111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "channel", + } +) diff --git a/tests/scenario_tests_async/test_events_assistant.py b/tests/scenario_tests_async/test_events_assistant.py new file mode 100644 index 000000000..f8ed97af9 --- /dev/null +++ b/tests/scenario_tests_async/test_events_assistant.py @@ -0,0 +1,274 @@ +import asyncio + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.context.async_context import AsyncBoltContext +from slack_bolt.context.say.async_say import AsyncSay +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.assistant.async_assistant import AsyncAssistant +from slack_bolt.request.async_request import AsyncBoltRequest +from tests.mock_web_api_server import ( + cleanup_mock_web_api_server_async, + setup_mock_web_api_server_async, +) +from tests.utils import remove_os_env_temporarily, restore_os_env, get_event_loop + + +class TestAsyncEventsAssistant: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server_async(self) + loop = get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server_async(self) + finally: + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_assistant_events(self): + app = AsyncApp(client=self.web_client) + + assistant = AsyncAssistant() + + state = {"called": False} + + async def assert_target_called(): + count = 0 + while state["called"] is False and count < 20: + await asyncio.sleep(0.1) + count += 1 + assert state["called"] is True + state["called"] = False + + @assistant.thread_started + async def start_thread(say: AsyncSay, set_suggested_prompts: AsyncSetSuggestedPrompts, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + await say("Hi, how can I help you today?") + await set_suggested_prompts( + prompts=[{"title": "What does SLACK stand for?", "message": "What does SLACK stand for?"}] + ) + state["called"] = True + + @assistant.thread_context_changed + async def handle_user_message(context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + state["called"] = True + + @assistant.user_message + async def handle_user_message(say: AsyncSay, set_status: AsyncSetStatus, context: AsyncBoltContext): + assert context.channel_id == "D111" + assert context.thread_ts == "1726133698.626339" + try: + await set_status("is typing...") + await say("Here you are!") + state["called"] = True + except Exception as e: + await say(f"Oops, something went wrong (error: {e}") + + app.assistant(assistant) + + request = AsyncBoltRequest(body=thread_started_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + request = AsyncBoltRequest(body=thread_context_changed_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + request = AsyncBoltRequest(body=user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + await assert_target_called() + + request = AsyncBoltRequest(body=message_changed_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 200 + + request = AsyncBoltRequest(body=channel_user_message_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 404 + + request = AsyncBoltRequest(body=channel_message_changed_event_body, mode="socket_mode") + response = await app.async_dispatch(request) + assert response.status == 404 + + +def build_payload(event: dict) -> dict: + return { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": event, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1599616881, + "authorizations": [ + { + "enterprise_id": "E111", + "team_id": "T111", + "user_id": "W111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + } + + +thread_started_event_body = build_payload( + { + "type": "assistant_thread_started", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C222", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + +thread_context_changed_event_body = build_payload( + { + "type": "assistant_thread_context_changed", + "assistant_thread": { + "user_id": "W222", + "context": {"channel_id": "C333", "team_id": "T111", "enterprise_id": "E111"}, + "channel_id": "D111", + "thread_ts": "1726133698.626339", + }, + "event_ts": "1726133698.665188", + } +) + + +user_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "When Slack was released?", + "team": "T111", + "user_team": "T111", + "source_team": "T222", + "user_profile": {}, + "thread_ts": "1726133698.626339", + "parent_user_id": "W222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "im", + } +) + + +message_changed_event_body = build_payload( + { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "assistant_app_thread": {"title": "When Slack was released?", "title_blocks": [], "artifacts": []}, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "subtype": "assistant_app_thread", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "D111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "im", + } +) + +channel_user_message_event_body = build_payload( + { + "user": "W222", + "type": "message", + "ts": "1726133700.887259", + "text": "When Slack was released?", + "team": "T111", + "user_team": "T111", + "source_team": "T222", + "user_profile": {}, + "thread_ts": "1726133698.626339", + "parent_user_id": "W222", + "channel": "D111", + "event_ts": "1726133700.887259", + "channel_type": "channel", + } +) + +channel_message_changed_event_body = build_payload( + { + "type": "message", + "subtype": "message_changed", + "message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + "ts": "1726133698.626339", + }, + "previous_message": { + "text": "New chat", + "user": "U222", + "type": "message", + "edited": {}, + "thread_ts": "1726133698.626339", + "reply_count": 2, + "reply_users_count": 2, + "latest_reply": "1726133700.887259", + "reply_users": ["U222", "W111"], + "is_locked": False, + }, + "channel": "D111", + "hidden": True, + "ts": "1726133701.028300", + "event_ts": "1726133701.028300", + "channel_type": "channel", + } +)