├── .flake8 ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── black.yml │ └── flake8.yml ├── .gitignore ├── LICENSE ├── README.md ├── app.py ├── app_oauth.py ├── listeners ├── __init__.py ├── assistant.py ├── events │ ├── __init__.py │ ├── assistant_thread_started.py │ ├── asssistant_thread_context_changed.py │ ├── thread_context_store.py │ └── user_message.py └── llm_caller.py ├── manifest.json ├── pyproject.toml ├── requirements.txt └── tests └── __init__.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 200 3 | exclude = .gitignore,venv 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code owners are the default owners for everything in 2 | # this repository. The owners listed below will be requested for 3 | # review when a pull request is opened. 4 | # To add code owner(s), uncomment the line below and 5 | # replace the @global-owner users with their GitHub username(s). 6 | # * @global-owner1 @global-owner2 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | labels: 8 | - "pip" 9 | - "dependencies" 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Formatting validation using black 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 5 12 | strategy: 13 | matrix: 14 | python-version: ['3.9'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | pip install -U pip 25 | pip install -r requirements.txt 26 | - name: Format with black 27 | run: | 28 | black . 29 | if git status --porcelain | grep .; then git --no-pager diff; exit 1; fi -------------------------------------------------------------------------------- /.github/workflows/flake8.yml: -------------------------------------------------------------------------------- 1 | name: Linting validation using flake8 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 5 12 | strategy: 13 | matrix: 14 | python-version: ['3.9'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | pip install -U pip 25 | pip install -r requirements.txt 26 | - name: Lint with flake8 27 | run: | 28 | flake8 *.py && flake8 listeners/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # general things to ignore 2 | build/ 3 | dist/ 4 | docs/_sources/ 5 | docs/.doctrees 6 | .eggs/ 7 | *.egg-info/ 8 | *.egg 9 | *.py[cod] 10 | __pycache__/ 11 | *.so 12 | *~ 13 | 14 | # virtualenv 15 | env*/ 16 | venv/ 17 | .venv* 18 | .env* 19 | 20 | # codecov / coverage 21 | .coverage 22 | cov_* 23 | coverage.xml 24 | 25 | # due to using tox and pytest 26 | .tox 27 | .cache 28 | .pytest_cache/ 29 | .python-version 30 | pip 31 | .mypy_cache/ 32 | 33 | # misc 34 | tmp.txt 35 | .DS_Store 36 | logs/ 37 | *.db 38 | .pytype/ 39 | .idea/ 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Slack Technologies, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # App Agent & Assistant Template (Bolt for Python) 2 | 3 | This Bolt for Python template demonstrates how to build [Agents & Assistants](https://api.slack.com/docs/apps/ai) in Slack. 4 | 5 | ## Setup 6 | 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). 7 | 8 | ### Developer Program 9 | Join the [Slack Developer Program](https://api.slack.com/developer-program) for exclusive access to sandbox environments for building and testing your apps, tooling, and resources created to help you build and grow. 10 | 11 | ## Installation 12 | 13 | #### Create a Slack App 14 | 1. Open [https://api.slack.com/apps/new](https://api.slack.com/apps/new) and choose "From an app manifest" 15 | 2. Choose the workspace you want to install the application to 16 | 3. Copy the contents of [manifest.json](./manifest.json) into the text box that says `*Paste your manifest code here*` (within the JSON tab) and click *Next* 17 | 4. Review the configuration and click *Create* 18 | 5. Click *Install to Workspace* and *Allow* on the screen that follows. You'll then be redirected to the App Configuration dashboard. 19 | 20 | #### Environment Variables 21 | Before you can run the app, you'll need to store some environment variables. 22 | 23 | 1. Open your app configuration page from this list, click **OAuth & Permissions** in the left hand menu, then copy the Bot User OAuth Token. You will store this in your environment as `SLACK_BOT_TOKEN`. 24 | 2. Click **Basic Information** from the left hand menu and follow the steps in the App-Level Tokens section to create an app-level token with the `connections:write` scope. Copy this token. You will store this in your environment as `SLACK_APP_TOKEN`. 25 | 26 | ```zsh 27 | # Replace with your app token and bot token 28 | # For Windows OS, env:SLACK_BOT_TOKEN = works 29 | export SLACK_BOT_TOKEN= 30 | export SLACK_APP_TOKEN= 31 | # This sample uses OpenAI's API by default, but you can switch to any other solution! 32 | export OPENAI_API_KEY= 33 | ``` 34 | 35 | ### Setup Your Local Project 36 | ```zsh 37 | # Clone this project onto your machine 38 | git clone https://github.com/slack-samples/bolt-python-assistant-template.git 39 | 40 | # Change into this project directory 41 | cd bolt-python-assistant-template 42 | 43 | # Setup your python virtual environment 44 | python3 -m venv .venv 45 | source .venv/bin/activate # for Windows OS, .\.venv\Scripts\Activate instead should work 46 | 47 | # Install the dependencies 48 | pip install -r requirements.txt 49 | 50 | # Start your local server 51 | python3 app.py 52 | ``` 53 | 54 | #### Linting 55 | ```zsh 56 | # Run flake8 from root directory for linting 57 | flake8 *.py && flake8 listeners/ 58 | 59 | # Run black from root directory for code formatting 60 | black . 61 | ``` 62 | 63 | ## Project Structure 64 | 65 | ### `manifest.json` 66 | 67 | `manifest.json` is a configuration for Slack apps. With a manifest, you can create an app with a pre-defined configuration, or adjust the configuration of an existing app. 68 | 69 | ### `app.py` 70 | 71 | `app.py` is the entry point for the application and is the file you'll run to start the server. This project aims to keep this file as thin as possible, primarily using it as a way to route inbound requests. 72 | 73 | ### `/listeners` 74 | 75 | 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. 76 | 77 | ## App Distribution / OAuth 78 | 79 | Only implement OAuth if you plan to distribute your application across multiple workspaces. A separate `app_oauth.py` file can be found with relevant OAuth settings. 80 | 81 | When using OAuth, Slack requires a public URL where it can send requests. In this template app, we've used [`ngrok`](https://ngrok.com/download). Checkout [this guide](https://ngrok.com/docs#getting-started-expose) for setting it up. 82 | 83 | Start `ngrok` to access the app on an external network and create a redirect URL for OAuth. 84 | 85 | ``` 86 | ngrok http 3000 87 | ``` 88 | 89 | This output should include a forwarding address for `http` and `https` (we'll use `https`). It should look something like the following: 90 | 91 | ``` 92 | Forwarding https://3cb89939.ngrok.io -> http://localhost:3000 93 | ``` 94 | 95 | Navigate to **OAuth & Permissions** in your app configuration and click **Add a Redirect URL**. The redirect URL should be set to your `ngrok` forwarding address with the `slack/oauth_redirect` path appended. For example: 96 | 97 | ``` 98 | https://3cb89939.ngrok.io/slack/oauth_redirect 99 | ``` 100 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from slack_bolt import App 5 | from slack_bolt.adapter.socket_mode import SocketModeHandler 6 | 7 | from listeners import register_listeners 8 | 9 | # Initialization 10 | logging.basicConfig(level=logging.DEBUG) 11 | app = App(token=os.environ.get("SLACK_BOT_TOKEN")) 12 | 13 | # Register Listeners 14 | register_listeners(app) 15 | 16 | # Start Bolt app 17 | if __name__ == "__main__": 18 | SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start() 19 | -------------------------------------------------------------------------------- /app_oauth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from slack_bolt import App, BoltResponse 4 | from slack_bolt.oauth.callback_options import CallbackOptions, SuccessArgs, FailureArgs 5 | from slack_bolt.oauth.oauth_settings import OAuthSettings 6 | 7 | from slack_sdk.oauth.installation_store import FileInstallationStore 8 | from slack_sdk.oauth.state_store import FileOAuthStateStore 9 | 10 | from listeners import register_listeners 11 | 12 | logging.basicConfig(level=logging.DEBUG) 13 | 14 | 15 | # Callback to run on successful installation 16 | def success(args: SuccessArgs) -> BoltResponse: 17 | # Call default handler to return an HTTP response 18 | return args.default.success(args) 19 | # return BoltResponse(status=200, body="Installation successful!") 20 | 21 | 22 | # Callback to run on failed installation 23 | def failure(args: FailureArgs) -> BoltResponse: 24 | return args.default.failure(args) 25 | # return BoltResponse(status=args.suggested_status_code, body=args.reason) 26 | 27 | 28 | # Initialization 29 | app = App( 30 | signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), 31 | installation_store=FileInstallationStore(), 32 | oauth_settings=OAuthSettings( 33 | client_id=os.environ.get("SLACK_CLIENT_ID"), 34 | client_secret=os.environ.get("SLACK_CLIENT_SECRET"), 35 | scopes=[ 36 | "assistant:write", 37 | "im:history", 38 | "chat:write", 39 | "channels:join", # required only for the channel summary 40 | "channels:history", # required only for the channel summary 41 | "groups:history", # required only for the channel summary 42 | ], 43 | user_scopes=[], 44 | redirect_uri=None, 45 | install_path="/slack/install", 46 | redirect_uri_path="/slack/oauth_redirect", 47 | state_store=FileOAuthStateStore(expiration_seconds=600), 48 | callback_options=CallbackOptions(success=success, failure=failure), 49 | ), 50 | ) 51 | 52 | # Register Listeners 53 | register_listeners(app) 54 | 55 | # Start Bolt app 56 | if __name__ == "__main__": 57 | app.start(3000) 58 | -------------------------------------------------------------------------------- /listeners/__init__.py: -------------------------------------------------------------------------------- 1 | from .assistant import assistant 2 | 3 | 4 | def register_listeners(app): 5 | # Using assistant middleware is the recommended way. 6 | app.assistant(assistant) 7 | 8 | # The following event listeners demonstrate how to implement the same on your own. 9 | # from listeners import events 10 | # events.register(app) 11 | -------------------------------------------------------------------------------- /listeners/assistant.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Dict 3 | from slack_bolt import Assistant, BoltContext, Say, SetSuggestedPrompts, SetStatus 4 | from slack_bolt.context.get_thread_context import GetThreadContext 5 | from slack_sdk import WebClient 6 | from slack_sdk.errors import SlackApiError 7 | 8 | from .llm_caller import call_llm 9 | 10 | # Refer to https://tools.slack.dev/bolt-python/concepts/assistant/ for more details 11 | assistant = Assistant() 12 | 13 | 14 | # This listener is invoked when a human user opened an assistant thread 15 | @assistant.thread_started 16 | def start_assistant_thread( 17 | say: Say, 18 | get_thread_context: GetThreadContext, 19 | set_suggested_prompts: SetSuggestedPrompts, 20 | logger: logging.Logger, 21 | ): 22 | try: 23 | say("How can I help you?") 24 | 25 | prompts: List[Dict[str, str]] = [ 26 | { 27 | "title": "What does Slack stand for?", 28 | "message": "Slack, a business communication service, was named after an acronym. Can you guess what it stands for?", 29 | }, 30 | { 31 | "title": "Write a draft announcement", 32 | "message": "Can you write a draft announcement about a new feature my team just released? It must include how impactful it is.", 33 | }, 34 | { 35 | "title": "Suggest names for my Slack app", 36 | "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.", 37 | }, 38 | ] 39 | 40 | thread_context = get_thread_context() 41 | if thread_context is not None and thread_context.channel_id is not None: 42 | summarize_channel = { 43 | "title": "Summarize the referred channel", 44 | "message": "Can you generate a brief summary of the referred channel?", 45 | } 46 | prompts.append(summarize_channel) 47 | 48 | set_suggested_prompts(prompts=prompts) 49 | except Exception as e: 50 | logger.exception(f"Failed to handle an assistant_thread_started event: {e}", e) 51 | say(f":warning: Something went wrong! ({e})") 52 | 53 | 54 | # This listener is invoked when the human user sends a reply in the assistant thread 55 | @assistant.user_message 56 | def respond_in_assistant_thread( 57 | payload: dict, 58 | logger: logging.Logger, 59 | context: BoltContext, 60 | set_status: SetStatus, 61 | get_thread_context: GetThreadContext, 62 | client: WebClient, 63 | say: Say, 64 | ): 65 | try: 66 | user_message = payload["text"] 67 | set_status("is typing...") 68 | 69 | if user_message == "Can you generate a brief summary of the referred channel?": 70 | # the logic here requires the additional bot scopes: 71 | # channels:join, channels:history, groups:history 72 | thread_context = get_thread_context() 73 | referred_channel_id = thread_context.get("channel_id") 74 | try: 75 | channel_history = client.conversations_history(channel=referred_channel_id, limit=50) 76 | except SlackApiError as e: 77 | if e.response["error"] == "not_in_channel": 78 | # If this app's bot user is not in the public channel, 79 | # we'll try joining the channel and then calling the same API again 80 | client.conversations_join(channel=referred_channel_id) 81 | channel_history = client.conversations_history(channel=referred_channel_id, limit=50) 82 | else: 83 | raise e 84 | 85 | prompt = f"Can you generate a brief summary of these messages in a Slack channel <#{referred_channel_id}>?\n\n" 86 | for message in reversed(channel_history.get("messages")): 87 | if message.get("user") is not None: 88 | prompt += f"\n<@{message['user']}> says: {message['text']}\n" 89 | messages_in_thread = [{"role": "user", "content": prompt}] 90 | returned_message = call_llm(messages_in_thread) 91 | say(returned_message) 92 | return 93 | 94 | replies = client.conversations_replies( 95 | channel=context.channel_id, 96 | ts=context.thread_ts, 97 | oldest=context.thread_ts, 98 | limit=10, 99 | ) 100 | messages_in_thread: List[Dict[str, str]] = [] 101 | for message in replies["messages"]: 102 | role = "user" if message.get("bot_id") is None else "assistant" 103 | messages_in_thread.append({"role": role, "content": message["text"]}) 104 | returned_message = call_llm(messages_in_thread) 105 | say(returned_message) 106 | 107 | except Exception as e: 108 | logger.exception(f"Failed to handle a user message event: {e}") 109 | say(f":warning: Something went wrong! ({e})") 110 | -------------------------------------------------------------------------------- /listeners/events/__init__.py: -------------------------------------------------------------------------------- 1 | # This sample app repository contains event listener code to help developers understand what's happening under the hood. 2 | # We recommend using assistant middleware instead of these event listeners. 3 | # For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. 4 | 5 | from typing import Dict, Any 6 | 7 | from slack_bolt import App 8 | from slack_bolt.request.payload_utils import is_event 9 | 10 | from .assistant_thread_started import start_thread_with_suggested_prompts 11 | from .asssistant_thread_context_changed import save_new_thread_context 12 | from .user_message import respond_to_user_message 13 | 14 | 15 | def register(app: App): 16 | app.event("assistant_thread_started")(start_thread_with_suggested_prompts) 17 | app.event("assistant_thread_context_changed")(save_new_thread_context) 18 | app.event("message", matchers=[is_user_message_event_in_assistant_thread])(respond_to_user_message) 19 | app.event("message", matchers=[is_message_event_in_assistant_thread])(just_ack) 20 | 21 | 22 | def is_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: 23 | if is_event(body): 24 | return body["event"]["type"] == "message" and body["event"].get("channel_type") == "im" 25 | return False 26 | 27 | 28 | def is_user_message_event_in_assistant_thread(body: Dict[str, Any]) -> bool: 29 | return is_message_event_in_assistant_thread(body) and body["event"].get("subtype") in (None, "file_share") 30 | 31 | 32 | def just_ack(): 33 | pass 34 | -------------------------------------------------------------------------------- /listeners/events/assistant_thread_started.py: -------------------------------------------------------------------------------- 1 | # This sample app repository contains event listener code to help developers understand what's happening under the hood. 2 | # We recommend using assistant middleware instead of these event listeners. 3 | # For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. 4 | 5 | from typing import List, Dict 6 | from logging import Logger 7 | 8 | from slack_sdk import WebClient 9 | 10 | 11 | def start_thread_with_suggested_prompts( 12 | payload: dict, 13 | client: WebClient, 14 | logger: Logger, 15 | ): 16 | thread = payload["assistant_thread"] 17 | channel_id, thread_ts = thread["channel_id"], thread["thread_ts"] 18 | try: 19 | thread_context = thread.get("context") 20 | message_metadata = ( 21 | { 22 | "event_type": "assistant_thread_context", 23 | "event_payload": thread_context, 24 | } 25 | if bool(thread_context) is True # the dict is not empty 26 | else None 27 | ) 28 | client.chat_postMessage( 29 | text="How can I help you?", 30 | channel=channel_id, 31 | thread_ts=thread_ts, 32 | metadata=message_metadata, 33 | ) 34 | 35 | prompts: List[Dict[str, str]] = [ 36 | { 37 | "title": "What does Slack stand for?", 38 | "message": "Slack, a business communication service, was named after an acronym. Can you guess what it stands for?", 39 | }, 40 | { 41 | "title": "Write a draft announcement", 42 | "message": "Can you write a draft announcement about a new feature my team just released? It must include how impactful it is.", 43 | }, 44 | { 45 | "title": "Suggest names for my Slack app", 46 | "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.", 47 | }, 48 | ] 49 | if message_metadata is not None: 50 | prompts.append( 51 | { 52 | "title": "Summarize the referred channel", 53 | "message": "Can you generate a brief summary of the referred channel?", 54 | } 55 | ) 56 | 57 | client.assistant_threads_setSuggestedPrompts( 58 | channel_id=channel_id, 59 | thread_ts=thread_ts, 60 | prompts=prompts, 61 | ) 62 | except Exception as e: 63 | logger.exception(f"Failed to handle an assistant_thread_started event: {e}", e) 64 | client.chat_postMessage( 65 | channel=channel_id, 66 | thread_ts=thread_ts, 67 | text=f":warning: Something went wrong! ({e})", 68 | ) 69 | -------------------------------------------------------------------------------- /listeners/events/asssistant_thread_context_changed.py: -------------------------------------------------------------------------------- 1 | # This sample app repository contains event listener code to help developers understand what's happening under the hood. 2 | # We recommend using assistant middleware instead of these event listeners. 3 | # For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. 4 | 5 | from slack_sdk import WebClient 6 | from slack_bolt import BoltContext 7 | 8 | from .thread_context_store import save_thread_context 9 | 10 | 11 | def save_new_thread_context( 12 | payload: dict, 13 | client: WebClient, 14 | context: BoltContext, 15 | ): 16 | thread = payload["assistant_thread"] 17 | save_thread_context( 18 | context=context, 19 | client=client, 20 | channel_id=thread["channel_id"], 21 | thread_ts=thread["thread_ts"], 22 | new_context=thread.get("context"), 23 | ) 24 | -------------------------------------------------------------------------------- /listeners/events/thread_context_store.py: -------------------------------------------------------------------------------- 1 | # This sample app repository contains event listener code to help developers understand what's happening under the hood. 2 | # We recommend using assistant middleware instead of these event listeners. 3 | # For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. 4 | 5 | from typing import Optional 6 | from slack_sdk import WebClient 7 | from slack_bolt import BoltContext 8 | 9 | 10 | def _find_parent_message( 11 | *, 12 | context: BoltContext, 13 | client: WebClient, 14 | channel_id: str, 15 | thread_ts: str, 16 | ) -> Optional[dict]: 17 | response = client.conversations_replies( 18 | channel=channel_id, 19 | ts=thread_ts, 20 | oldest=thread_ts, 21 | include_all_metadata=True, 22 | limit=4, 23 | ) 24 | if response.get("messages"): 25 | for message in response.get("messages"): 26 | if message.get("subtype") is None and message.get("user") == context.bot_user_id: 27 | return message 28 | 29 | 30 | def get_thread_context( 31 | *, 32 | context: BoltContext, 33 | client: WebClient, 34 | channel_id: str, 35 | thread_ts: str, 36 | ) -> Optional[dict]: 37 | parent_message = _find_parent_message(context=context, client=client, channel_id=channel_id, thread_ts=thread_ts) 38 | if parent_message is not None and parent_message.get("metadata") is not None: 39 | return parent_message["metadata"]["event_payload"] 40 | 41 | 42 | def save_thread_context( 43 | *, 44 | context: BoltContext, 45 | client: WebClient, 46 | channel_id: str, 47 | thread_ts: str, 48 | new_context: dict, 49 | ) -> None: 50 | parent_message = _find_parent_message( 51 | context=context, 52 | client=client, 53 | channel_id=channel_id, 54 | thread_ts=thread_ts, 55 | ) 56 | if parent_message is not None: 57 | client.chat_update( 58 | channel=channel_id, 59 | ts=parent_message["ts"], 60 | text=parent_message["text"], 61 | blocks=parent_message.get("blocks"), 62 | metadata={ 63 | "event_type": "assistant_thread_context", 64 | "event_payload": new_context, 65 | }, 66 | ) 67 | -------------------------------------------------------------------------------- /listeners/events/user_message.py: -------------------------------------------------------------------------------- 1 | # This sample app repository contains event listener code to help developers understand what's happening under the hood. 2 | # We recommend using assistant middleware instead of these event listeners. 3 | # For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. 4 | 5 | from typing import List, Dict 6 | from logging import Logger 7 | 8 | from slack_sdk.web import WebClient 9 | from slack_sdk.errors import SlackApiError 10 | from slack_bolt import BoltContext 11 | from ..llm_caller import call_llm 12 | from .thread_context_store import get_thread_context 13 | 14 | 15 | def respond_to_user_message( 16 | payload: dict, 17 | client: WebClient, 18 | context: BoltContext, 19 | logger: Logger, 20 | ): 21 | channel_id, thread_ts = payload["channel"], payload["thread_ts"] 22 | try: 23 | user_message = payload["text"] 24 | thread_context = get_thread_context( 25 | context=context, 26 | client=client, 27 | channel_id=channel_id, 28 | thread_ts=thread_ts, 29 | ) 30 | 31 | client.assistant_threads_setStatus( 32 | channel_id=channel_id, 33 | thread_ts=thread_ts, 34 | status="is typing...", 35 | ) 36 | if user_message == "Can you generate a brief summary of the referred channel?": 37 | # the logic here requires the additional bot scopes: 38 | # channels:join, channels:history, groups:history 39 | referred_channel_id = thread_context.get("channel_id") 40 | try: 41 | channel_history = client.conversations_history( 42 | channel=referred_channel_id, 43 | limit=50, 44 | ) 45 | except SlackApiError as e: 46 | if e.response["error"] == "not_in_channel": 47 | # If this app's bot user is not in the public channel, 48 | # we'll try joining the channel and then calling the same API again 49 | client.conversations_join(channel=referred_channel_id) 50 | channel_history = client.conversations_history( 51 | channel=referred_channel_id, 52 | limit=50, 53 | ) 54 | else: 55 | raise e 56 | 57 | prompt = f"Can you generate a brief summary of these messages in a Slack channel <#{referred_channel_id}>?\n\n" 58 | for message in reversed(channel_history.get("messages")): 59 | if message.get("user") is not None: 60 | prompt += f"\n<@{message['user']}> says: {message['text']}\n" 61 | messages_in_thread = [{"role": "user", "content": prompt}] 62 | returned_message = call_llm(messages_in_thread) 63 | client.chat_postMessage( 64 | channel=channel_id, 65 | thread_ts=thread_ts, 66 | text=returned_message, 67 | ) 68 | return 69 | 70 | replies = client.conversations_replies( 71 | channel=channel_id, 72 | ts=thread_ts, 73 | oldest=thread_ts, 74 | limit=10, 75 | ) 76 | messages_in_thread: List[Dict[str, str]] = [] 77 | for message in replies["messages"]: 78 | role = "user" if message.get("bot_id") is None else "assistant" 79 | messages_in_thread.append({"role": role, "content": message["text"]}) 80 | returned_message = call_llm(messages_in_thread) 81 | client.chat_postMessage( 82 | channel=channel_id, 83 | thread_ts=thread_ts, 84 | text=returned_message, 85 | ) 86 | except Exception as e: 87 | logger.exception(f"Failed to handle a user message event: {e}") 88 | client.chat_postMessage( 89 | channel=channel_id, 90 | thread_ts=thread_ts, 91 | text=f":warning: Something went wrong! ({e})", 92 | ) 93 | -------------------------------------------------------------------------------- /listeners/llm_caller.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import List, Dict 4 | 5 | import openai 6 | 7 | DEFAULT_SYSTEM_CONTENT = """ 8 | You're an assistant in a Slack workspace. 9 | Users in the workspace will ask you to help them write something or to think better about a specific topic. 10 | You'll respond to those questions in a professional way. 11 | When you include markdown text, convert them to Slack compatible ones. 12 | When a prompt has Slack's special syntax like <@USER_ID> or <#CHANNEL_ID>, you must keep them as-is in your response. 13 | """ 14 | 15 | 16 | def call_llm( 17 | messages_in_thread: List[Dict[str, str]], 18 | system_content: str = DEFAULT_SYSTEM_CONTENT, 19 | ) -> str: 20 | openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) 21 | messages = [{"role": "system", "content": system_content}] 22 | messages.extend(messages_in_thread) 23 | response = openai_client.chat.completions.create( 24 | model="gpt-4o-mini", 25 | n=1, 26 | messages=messages, 27 | max_tokens=16384, 28 | ) 29 | return markdown_to_slack(response.choices[0].message.content) 30 | 31 | 32 | # Conversion from OpenAI markdown to Slack mrkdwn 33 | # See also: https://api.slack.com/reference/surfaces/formatting#basics 34 | def markdown_to_slack(content: str) -> str: 35 | # Split the input string into parts based on code blocks and inline code 36 | parts = re.split(r"(?s)(```.+?```|`[^`\n]+?`)", content) 37 | 38 | # Apply the bold, italic, and strikethrough formatting to text not within code 39 | result = "" 40 | for part in parts: 41 | if part.startswith("```") or part.startswith("`"): 42 | result += part 43 | else: 44 | for o, n in [ 45 | ( 46 | r"\*\*\*(?!\s)([^\*\n]+?)(?=1.21,<2 2 | slack-sdk>=3.33.1,<4 3 | # If you use a different LLM vendor, replace this dependency 4 | openai 5 | 6 | pytest 7 | flake8 8 | black 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022, Slack Technologies, LLC. All rights reserved. 2 | --------------------------------------------------------------------------------