diff --git a/.flake8 b/.flake8 index 3a986c2..e8411f0 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] -max-line-length = 125 +max-line-length = 200 exclude = .gitignore,venv diff --git a/.gitignore b/.gitignore index 954bfb3..abc3d97 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ tmp.txt .DS_Store logs/ *.db -.pytype/ \ No newline at end of file +.pytype/ +.idea/ diff --git a/README.md b/README.md index 4e32b5a..4a9f20b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Bolt for Python Template App +# App Assistant Sample (Bolt for Python) -This is a generic Bolt for Python template app used to build out Slack apps. +This Bolt for Python sample app demonstrates how to use [Agents & Assistants](https://api.slack.com/docs/apps/ai) in Slack. 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). ## Installation @@ -22,15 +22,17 @@ Before you can run the app, you'll need to store some environment variables. # Replace with your app token and bot token export SLACK_BOT_TOKEN= export SLACK_APP_TOKEN= +# This sample uses OpenAI's API by default, but you can switch to any other solution! +export OPENAI_API_KEY= ``` ### Setup Your Local Project ```zsh # Clone this project onto your machine -git clone https://github.com/slack-samples/bolt-python-starter-template.git +git clone https://github.com/slack-samples/bolt-python-app-assistant.git # Change into this project directory -cd bolt-python-starter-template +cd bolt-python-app-assistant # Setup your python virtual environment python3 -m venv .venv diff --git a/app.py b/app.py index 854f80e..092471f 100644 --- a/app.py +++ b/app.py @@ -7,8 +7,8 @@ from listeners import register_listeners # Initialization -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) logging.basicConfig(level=logging.DEBUG) +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) # Register Listeners register_listeners(app) diff --git a/app_oauth.py b/app_oauth.py index 190e286..043b1ee 100644 --- a/app_oauth.py +++ b/app_oauth.py @@ -32,7 +32,14 @@ def failure(args: FailureArgs) -> BoltResponse: oauth_settings=OAuthSettings( client_id=os.environ.get("SLACK_CLIENT_ID"), client_secret=os.environ.get("SLACK_CLIENT_SECRET"), - scopes=["channels:history", "chat:write", "commands"], + scopes=[ + "assistant:write", + "im:history", + "chat:write", + "channels:join", # required only for the channel summary + "channels:history", # required only for the channel summary + "groups:history", # required only for the channel summary + ], user_scopes=[], redirect_uri=None, install_path="/slack/install", diff --git a/listeners/__init__.py b/listeners/__init__.py index f95a68d..fee0334 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,15 +1,5 @@ -from listeners import actions -from listeners import commands from listeners import events -from listeners import messages -from listeners import shortcuts -from listeners import views def register_listeners(app): - actions.register(app) - commands.register(app) events.register(app) - messages.register(app) - shortcuts.register(app) - views.register(app) diff --git a/listeners/actions/__init__.py b/listeners/actions/__init__.py deleted file mode 100644 index 75536bf..0000000 --- a/listeners/actions/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from slack_bolt import App -from .sample_action import sample_action_callback - - -def register(app: App): - app.action("sample_action_id")(sample_action_callback) diff --git a/listeners/actions/sample_action.py b/listeners/actions/sample_action.py deleted file mode 100644 index 76425b4..0000000 --- a/listeners/actions/sample_action.py +++ /dev/null @@ -1,64 +0,0 @@ -from logging import Logger - -from slack_bolt import Ack -from slack_sdk import WebClient - - -def sample_action_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): - try: - ack() - client.views_update( - view_id=body["view"]["id"], - hash=body["view"]["hash"], - view={ - "type": "modal", - "callback_id": "sample_view_id", - "title": { - "type": "plain_text", - "text": "Update modal title", - }, - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Nice! You updated the modal! 🎉", - }, - }, - { - "type": "image", - "image_url": "https://media.giphy.com/media/SVZGEcYt7brkFUyU90/giphy.gif", - "alt_text": "Yay! The modal was updated", - }, - { - "type": "input", - "block_id": "input_block_id", - "label": { - "type": "plain_text", - "text": "What are your hopes and dreams?", - }, - "element": { - "type": "plain_text_input", - "action_id": "sample_input_id", - "multiline": True, - }, - }, - { - "block_id": "select_channel_block_id", - "type": "input", - "label": { - "type": "plain_text", - "text": "Select a channel to message the result to", - }, - "element": { - "type": "conversations_select", - "action_id": "sample_dropdown_id", - "response_url_enabled": True, - }, - }, - ], - "submit": {"type": "plain_text", "text": "Submit"}, - }, - ) - except Exception as e: - logger.error(e) diff --git a/listeners/commands/__init__.py b/listeners/commands/__init__.py deleted file mode 100644 index 8e67e24..0000000 --- a/listeners/commands/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from slack_bolt import App -from .sample_command import sample_command_callback - - -def register(app: App): - app.command("/sample-command")(sample_command_callback) diff --git a/listeners/commands/sample_command.py b/listeners/commands/sample_command.py deleted file mode 100644 index 7bd4bc7..0000000 --- a/listeners/commands/sample_command.py +++ /dev/null @@ -1,10 +0,0 @@ -from slack_bolt import Ack, Respond -from logging import Logger - - -def sample_command_callback(command, ack: Ack, respond: Respond, logger: Logger): - try: - ack() - respond(f"Responding to the sample command! Your command was: {command['text']}") - except Exception as e: - logger.error(e) diff --git a/listeners/events/__init__.py b/listeners/events/__init__.py index 67a6f69..fe3e3c5 100644 --- a/listeners/events/__init__.py +++ b/listeners/events/__init__.py @@ -1,6 +1,29 @@ +from typing import Dict, Any + from slack_bolt import App -from .app_home_opened import app_home_opened_callback +from slack_bolt.request.payload_utils import is_event + +from .assistant_thread_started import start_thread_with_suggested_prompts +from .asssistant_thread_context_changed import save_new_thread_context +from .user_message import respond_to_user_message def register(app: App): - app.event("app_home_opened")(app_home_opened_callback) + app.event("assistant_thread_started")(start_thread_with_suggested_prompts) + app.event("assistant_thread_context_changed")(save_new_thread_context) + app.event("message", matchers=[is_user_message_event_in_assistant_thread])(respond_to_user_message) + app.event("message", matchers=[is_message_event_in_assistant_thread])(just_ack) + + +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: + return is_message_event_in_assistant_thread(body) and body["event"].get("subtype") in (None, "file_share") + + +def just_ack(): + pass diff --git a/listeners/events/app_home_opened.py b/listeners/events/app_home_opened.py deleted file mode 100644 index a2e106c..0000000 --- a/listeners/events/app_home_opened.py +++ /dev/null @@ -1,33 +0,0 @@ -from logging import Logger - - -def app_home_opened_callback(client, event, logger: Logger): - # ignore the app_home_opened event for anything but the Home tab - if event["tab"] != "home": - return - try: - client.views_publish( - user_id=event["user"], - view={ - "type": "home", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Welcome home, <@" + event["user"] + "> :house:*", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Learn how home tabs can be more useful and " - + "interactive .", - }, - }, - ], - }, - ) - except Exception as e: - logger.error(f"Error publishing home tab: {e}") diff --git a/listeners/events/assistant_thread_started.py b/listeners/events/assistant_thread_started.py new file mode 100644 index 0000000..0866d64 --- /dev/null +++ b/listeners/events/assistant_thread_started.py @@ -0,0 +1,64 @@ +from typing import List, Dict +from logging import Logger + +from slack_sdk import WebClient + + +def start_thread_with_suggested_prompts( + payload: dict, + client: WebClient, + logger: Logger, +): + thread = payload["assistant_thread"] + channel_id, thread_ts = thread["channel_id"], thread["thread_ts"] + try: + thread_context = thread.get("context") + message_metadata = ( + { + "event_type": "assistant_thread_context", + "event_payload": thread_context, + } + if bool(thread_context) is True # the dict is not empty + else None + ) + client.chat_postMessage( + text="How can I help you?", + channel=channel_id, + thread_ts=thread_ts, + metadata=message_metadata, + ) + + prompts: List[Dict[str, str]] = [ + { + "title": "What does Slack stand for?", + "message": "Slack, a business communication service, was named after an acronym. Can you guess what it stands for?", + }, + { + "title": "Write a draft announcement", + "message": "Can you write a draft announcement about a new feature my team just released? It must include how impactful it is.", + }, + { + "title": "Suggest names for my Slack app", + "message": "Can you suggest a few names for my Slack app? The app helps my teammates better organize information and plan priorities and action items.", + }, + ] + if message_metadata is not None: + prompts.append( + { + "title": "Summarize the referred channel", + "message": "Can you generate a brief summary of the referred channel?", + } + ) + + client.assistant_threads_setSuggestedPrompts( + channel_id=channel_id, + thread_ts=thread_ts, + prompts=prompts, + ) + except Exception as e: + logger.exception(f"Failed to handle an assistant_thread_started event: {e}", e) + client.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=f":warning: Something went wrong! ({e})", + ) diff --git a/listeners/events/asssistant_thread_context_changed.py b/listeners/events/asssistant_thread_context_changed.py new file mode 100644 index 0000000..502f528 --- /dev/null +++ b/listeners/events/asssistant_thread_context_changed.py @@ -0,0 +1,19 @@ +from slack_sdk import WebClient +from slack_bolt import BoltContext + +from .thread_context_store import save_thread_context + + +def save_new_thread_context( + payload: dict, + client: WebClient, + context: BoltContext, +): + thread = payload["assistant_thread"] + save_thread_context( + context=context, + client=client, + channel_id=thread["channel_id"], + thread_ts=thread["thread_ts"], + new_context=thread.get("context"), + ) diff --git a/listeners/events/llm_caller.py b/listeners/events/llm_caller.py new file mode 100644 index 0000000..a1e656c --- /dev/null +++ b/listeners/events/llm_caller.py @@ -0,0 +1,56 @@ +import os +import re +from typing import List, Dict + +import openai + +DEFAULT_SYSTEM_CONTENT = """ +You're an assistant in a Slack workspace. +Users in the workspace will ask you to help them write something or to think better about a specific topic. +You'll respond to those questions in a professional way. +When you include markdown text, convert them to Slack compatible ones. +When a prompt has Slack's special syntax like <@USER_ID> or <#CHANNEL_ID>, you must keep them as-is in your response. +""" + + +def call_llm(messages_in_thread: List[Dict[str, str]], system_content: str = DEFAULT_SYSTEM_CONTENT) -> str: + 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]+?)(? Optional[dict]: + response = client.conversations_replies( + channel=channel_id, + ts=thread_ts, + oldest=thread_ts, + include_all_metadata=True, + limit=4, + ) + if response.get("messages"): + for message in response.get("messages"): + if message.get("subtype") is None and message.get("user") == context.bot_user_id: + return message + + +def get_thread_context( + *, + context: BoltContext, + client: WebClient, + channel_id: str, + thread_ts: str, +) -> Optional[dict]: + parent_message = _find_parent_message(context=context, client=client, channel_id=channel_id, thread_ts=thread_ts) + if parent_message is not None and parent_message.get("metadata") is not None: + return parent_message["metadata"]["event_payload"] + + +def save_thread_context( + *, + context: BoltContext, + client: WebClient, + channel_id: str, + thread_ts: str, + new_context: dict, +) -> None: + parent_message = _find_parent_message( + context=context, + client=client, + channel_id=channel_id, + thread_ts=thread_ts, + ) + if parent_message is not None: + client.chat_update( + channel=channel_id, + ts=parent_message["ts"], + text=parent_message["text"], + blocks=parent_message.get("blocks"), + metadata={ + "event_type": "assistant_thread_context", + "event_payload": new_context, + }, + ) diff --git a/listeners/events/user_message.py b/listeners/events/user_message.py new file mode 100644 index 0000000..4f870a8 --- /dev/null +++ b/listeners/events/user_message.py @@ -0,0 +1,88 @@ +from typing import List, Dict +from logging import Logger + +from slack_sdk.web import WebClient +from slack_sdk.errors import SlackApiError +from slack_bolt import BoltContext +from .llm_caller import call_llm +from .thread_context_store import get_thread_context + + +def respond_to_user_message( + payload: dict, + client: WebClient, + context: BoltContext, + logger: Logger, +): + channel_id, thread_ts = payload["channel"], payload["thread_ts"] + try: + user_message = payload["text"] + thread_context = get_thread_context( + context=context, + client=client, + channel_id=channel_id, + thread_ts=thread_ts, + ) + + client.assistant_threads_setStatus( + channel_id=channel_id, + thread_ts=thread_ts, + 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 + 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 + + prompt = f"Can you generate a brief summary of these messages in a Slack channel <#{referred_channel_id}>?\n\n" + for message in 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) + client.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=returned_message, + ) + return + + replies = client.conversations_replies( + channel=channel_id, + ts=thread_ts, + oldest=thread_ts, + limit=10, + ) + messages_in_thread: List[Dict[str, str]] = [] + 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) + client.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=returned_message, + ) + except Exception as e: + logger.exception(f"Failed to handle a user message event: {e}") + client.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=f":warning: Something went wrong! ({e})", + ) diff --git a/listeners/messages/__init__.py b/listeners/messages/__init__.py deleted file mode 100644 index 752ed44..0000000 --- a/listeners/messages/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -import re - -from slack_bolt import App -from .sample_message import sample_message_callback - - -# To receive messages from a channel or dm your app must be a member! -def register(app: App): - app.message(re.compile("(hi|hello|hey)"))(sample_message_callback) diff --git a/listeners/messages/sample_message.py b/listeners/messages/sample_message.py deleted file mode 100644 index 29429a3..0000000 --- a/listeners/messages/sample_message.py +++ /dev/null @@ -1,12 +0,0 @@ -from logging import Logger - -from slack_bolt import BoltContext, Say -from slack_sdk import WebClient - - -def sample_message_callback(context: BoltContext, client: WebClient, say: Say, logger: Logger): - try: - greeting = context["matches"][0] - say(f"{greeting}, how are you?") - except Exception as e: - logger.error(e) diff --git a/listeners/shortcuts/__init__.py b/listeners/shortcuts/__init__.py deleted file mode 100644 index f775432..0000000 --- a/listeners/shortcuts/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from slack_bolt import App -from .sample_shortcut import sample_shortcut_callback - - -def register(app: App): - app.shortcut("sample_shortcut_id")(sample_shortcut_callback) diff --git a/listeners/shortcuts/sample_shortcut.py b/listeners/shortcuts/sample_shortcut.py deleted file mode 100644 index a92883e..0000000 --- a/listeners/shortcuts/sample_shortcut.py +++ /dev/null @@ -1,60 +0,0 @@ -from logging import Logger - -from slack_bolt import Ack -from slack_sdk import WebClient - - -def sample_shortcut_callback(body: dict, ack: Ack, client: WebClient, logger: Logger): - try: - ack() - client.views_open( - trigger_id=body["trigger_id"], - view={ - "type": "modal", - "callback_id": "sample_view_id", - "title": {"type": "plain_text", "text": "Sample modal title"}, - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Click the button to update the modal", - }, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Update modal"}, - "action_id": "sample_action_id", - }, - }, - { - "type": "input", - "block_id": "input_block_id", - "label": { - "type": "plain_text", - "text": "What are your hopes and dreams?", - }, - "element": { - "type": "plain_text_input", - "action_id": "sample_input_id", - "multiline": True, - }, - }, - { - "block_id": "select_channel_block_id", - "type": "input", - "label": { - "type": "plain_text", - "text": "Select a channel to message the result to", - }, - "element": { - "type": "conversations_select", - "action_id": "sample_dropdown_id", - "response_url_enabled": True, - }, - }, - ], - "submit": {"type": "plain_text", "text": "Submit"}, - }, - ) - except Exception as e: - logger.error(e) diff --git a/listeners/views/__init__.py b/listeners/views/__init__.py deleted file mode 100644 index 2984626..0000000 --- a/listeners/views/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from slack_bolt import App -from .sample_view import sample_view_callback - - -def register(app: App): - app.view("sample_view_id")(sample_view_callback) diff --git a/listeners/views/sample_view.py b/listeners/views/sample_view.py deleted file mode 100644 index e85ead6..0000000 --- a/listeners/views/sample_view.py +++ /dev/null @@ -1,22 +0,0 @@ -from logging import Logger - -from slack_bolt import Ack -from slack_sdk import WebClient - - -def sample_view_callback(view, ack: Ack, body: dict, client: WebClient, logger: Logger): - try: - ack() - sample_user_value = body["user"]["id"] - provided_values = view["state"]["values"] - logger.info(f"Provided values {provided_values}") - sample_input_value = provided_values["input_block_id"]["sample_input_id"]["value"] - sample_convo_value = provided_values["select_channel_block_id"]["sample_dropdown_id"]["selected_conversation"] - - client.chat_postMessage( - channel=sample_convo_value, - text=f"<@{sample_user_value}> submitted the following :sparkles: " - + f"hopes and dreams :sparkles:: \n\n {sample_input_value}", - ) - except Exception as e: - logger.error(e) diff --git a/manifest.json b/manifest.json index f973e42..329700b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,58 +1,47 @@ { - "_metadata": { - "major_version": 1, - "minor_version": 1 - }, "display_information": { - "name": "Bolt Template App" + "name": "Bolt Python Assistant" }, "features": { - "app_home": { - "home_tab_enabled": true, - "messages_tab_enabled": false, - "messages_tab_read_only_enabled": true - }, - "bot_user": { - "display_name": "Bolt Template App", - "always_online": false - }, - "shortcuts": [ - { - "name": "Run sample shortcut", - "type": "global", - "callback_id": "sample_shortcut_id", - "description": "Runs a sample shortcut" - } - ], - "slash_commands": [ - { - "command": "/sample-command", - "description": "Runs a sample command", - "should_escape": false - } - ] + "app_home": { + "home_tab_enabled": false, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "bot_user": { + "display_name": "Bolt Python Assistant", + "always_online": false + }, + "assistant_view": { + "assistant_description": "Hi, I am an assistant built using Bolt for Python. I am here to help you out!", + "suggested_prompts": [] + } }, "oauth_config": { - "scopes": { - "bot": [ - "channels:history", - "chat:write", - "commands" - ] - } + "scopes": { + "bot": [ + "assistant:write", + "channels:join", + "im:history", + "channels:history", + "groups:history", + "chat:write" + ] + } }, "settings": { - "event_subscriptions": { - "bot_events": [ - "app_home_opened", - "message.channels" - ] - }, - "interactivity": { - "is_enabled": true - }, - "org_deploy_enabled": false, - "socket_mode_enabled": true, - "token_rotation_enabled": false + "event_subscriptions": { + "bot_events": [ + "assistant_thread_context_changed", + "assistant_thread_started", + "message.im" + ] + }, + "interactivity": { + "is_enabled": false + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false } } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 48c9467..eb94218 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ -slack-bolt +slack-bolt>=1.20.1,<2 +slack-sdk>=3.33.1,<4 +# If you use a different LLM vendor, replace this dependency +openai + pytest -flake8==7.1.1 -black==24.8.0 +flake8 +black