diff --git a/README.md b/README.md index c2019a5..b03ebb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# App Agent & Assistant Template (Bolt for Python) +# AI App Template (Bolt for Python) -This Bolt for Python template demonstrates how to build [Agents & Assistants](https://api.slack.com/docs/apps/ai) in Slack. +This Bolt for Python template demonstrates how to build [AI Apps](https://docs.slack.dev/ai/) in Slack. ## Setup Before getting started, make sure you have a development workspace where you have permissions to install apps. If you don’t have one setup, go ahead and [create one](https://slack.com/create). @@ -72,7 +72,20 @@ black . ### `/listeners` -Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/events` handles incoming events, `/listeners/shortcuts` would handle incoming [Shortcuts](https://api.slack.com/interactivity/shortcuts) requests, and so on. +Every incoming request is routed to a "listener". This directory groups each listener based on the Slack Platform feature used, so `/listeners/events` handles incoming events, `/listeners/shortcuts` would handle incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, and so on. + +:::info[The `listeners/events` folder is purely educational and demonstrates alternative approaches to implementation] +These listeners are **not registered** and are not used in the actual application. For the working implementation, refer to `listeners/assistant.py`. + +**`/listeners/assistant`** + +Configures the new Slack Assistant features, providing a dedicated side panel UI for users to interact with the AI chatbot. This module includes: + +`assistant.py`, which contains two listeners: +* The `@assistant.thread_started` listener receives an event when users start new app thread. +* The `@assistant.user_message` listener processes user messages in app threads or from the app **Chat** and **History** tab. + +`llm_caller.py`, which handles OpenAI API integration and message formatting. It includes the `call_llm()` function that sends conversation threads to OpenAI's models. ## App Distribution / OAuth diff --git a/app.py b/app.py index 092471f..fb7bccf 100644 --- a/app.py +++ b/app.py @@ -3,13 +3,20 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient from listeners import register_listeners # Initialization logging.basicConfig(level=logging.DEBUG) -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + client=WebClient( + base_url=os.environ.get("SLACK_API_URL", "https://slack.com/api"), + token=os.environ.get("SLACK_BOT_TOKEN"), + ), +) # Register Listeners register_listeners(app) diff --git a/listeners/__init__.py b/listeners/__init__.py index 523ce09..bf9d7a2 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,4 +1,4 @@ -from .assistant import assistant +from listeners.assistant import assistant def register_listeners(app): diff --git a/listeners/assistant/__init__.py b/listeners/assistant/__init__.py new file mode 100644 index 0000000..1bbba68 --- /dev/null +++ b/listeners/assistant/__init__.py @@ -0,0 +1,3 @@ +from .assistant import assistant + +__all__ = ["assistant"] diff --git a/listeners/assistant.py b/listeners/assistant/assistant.py similarity index 62% rename from listeners/assistant.py rename to listeners/assistant/assistant.py index 817030f..1e37bda 100644 --- a/listeners/assistant.py +++ b/listeners/assistant/assistant.py @@ -1,11 +1,10 @@ import logging from typing import List, Dict -from slack_bolt import Assistant, BoltContext, Say, SetSuggestedPrompts, SetStatus +from slack_bolt import Assistant, BoltContext, Say, SetSuggestedPrompts from slack_bolt.context.get_thread_context import GetThreadContext from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from .llm_caller import call_llm +from ..llm_caller import call_llm # Refer to https://tools.slack.dev/bolt-python/concepts/assistant/ for more details assistant = Assistant() @@ -57,39 +56,20 @@ def respond_in_assistant_thread( payload: dict, logger: logging.Logger, context: BoltContext, - set_status: SetStatus, - get_thread_context: GetThreadContext, client: WebClient, say: Say, ): try: - user_message = payload["text"] - set_status("is typing...") - - if user_message == "Can you generate a brief summary of the referred channel?": - # the logic here requires the additional bot scopes: - # channels:join, channels:history, groups:history - thread_context = get_thread_context() - referred_channel_id = thread_context.get("channel_id") - try: - channel_history = client.conversations_history(channel=referred_channel_id, limit=50) - except SlackApiError as e: - if e.response["error"] == "not_in_channel": - # If this app's bot user is not in the public channel, - # we'll try joining the channel and then calling the same API again - client.conversations_join(channel=referred_channel_id) - channel_history = client.conversations_history(channel=referred_channel_id, limit=50) - else: - raise e + channel_id = payload["channel"] + thread_ts = payload["thread_ts"] - prompt = f"Can you generate a brief summary of these messages in a Slack channel <#{referred_channel_id}>?\n\n" - for message in reversed(channel_history.get("messages")): - if message.get("user") is not None: - prompt += f"\n<@{message['user']}> says: {message['text']}\n" - messages_in_thread = [{"role": "user", "content": prompt}] - returned_message = call_llm(messages_in_thread) - say(returned_message) - return + loading_messages = [ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ] replies = client.conversations_replies( channel=context.channel_id, @@ -101,8 +81,27 @@ def respond_in_assistant_thread( for message in replies["messages"]: role = "user" if message.get("bot_id") is None else "assistant" messages_in_thread.append({"role": role, "content": message["text"]}) + returned_message = call_llm(messages_in_thread) - say(returned_message) + client.assistant_threads_setStatus( + channel_id=channel_id, thread_ts=thread_ts, status="Bolt is typing", loading_messages=loading_messages + ) + stream_response = client.chat_startStream( + channel=channel_id, + thread_ts=thread_ts, + ) + stream_ts = stream_response["ts"] + # use of this for loop is specific to openai response method + for event in returned_message: + if event.type == "response.output_text.delta": + client.chat_appendStream(channel=channel_id, ts=stream_ts, markdown_text=f"{event.delta}") + else: + continue + + client.chat_stopStream( + channel=channel_id, + ts=stream_ts, + ) except Exception as e: logger.exception(f"Failed to handle a user message event: {e}") diff --git a/listeners/llm_caller.py b/listeners/llm_caller.py index 863c0eb..e808068 100644 --- a/listeners/llm_caller.py +++ b/listeners/llm_caller.py @@ -1,8 +1,10 @@ import os -import re from typing import List, Dict import openai +from openai import Stream +from openai.types.responses import ResponseStreamEvent + DEFAULT_SYSTEM_CONTENT = """ You're an assistant in a Slack workspace. @@ -16,44 +18,9 @@ def call_llm( messages_in_thread: List[Dict[str, str]], system_content: str = DEFAULT_SYSTEM_CONTENT, -) -> str: +) -> Stream[ResponseStreamEvent]: openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) messages = [{"role": "system", "content": system_content}] messages.extend(messages_in_thread) - response = openai_client.chat.completions.create( - model="gpt-4o-mini", - n=1, - messages=messages, - max_tokens=16384, - ) - return markdown_to_slack(response.choices[0].message.content) - - -# Conversion from OpenAI markdown to Slack mrkdwn -# See also: https://api.slack.com/reference/surfaces/formatting#basics -def markdown_to_slack(content: str) -> str: - # Split the input string into parts based on code blocks and inline code - parts = re.split(r"(?s)(```.+?```|`[^`\n]+?`)", content) - - # Apply the bold, italic, and strikethrough formatting to text not within code - result = "" - for part in parts: - if part.startswith("```") or part.startswith("`"): - result += part - else: - for o, n in [ - ( - r"\*\*\*(?!\s)([^\*\n]+?)(?=1.21,<2 -slack-sdk>=3.33.1,<4 + # If you use a different LLM vendor, replace this dependency openai