diff --git a/README.md b/README.md index 91f57b0..0ff0b3c 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,6 @@ black . 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. -> [!NOTE] -> 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/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: diff --git a/listeners/__init__.py b/listeners/__init__.py index 5ca09f3..4a53dc3 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,12 +1,9 @@ from listeners import actions from listeners import assistant +from listeners import events def register_listeners(app): - actions.register(app) assistant.register(app) - - # The following event listeners demonstrate how to implement the same on your own. - # from listeners import events - # events.register(app) + events.register(app) diff --git a/listeners/assistant/assistant.py b/listeners/assistant/assistant.py index bf0ff93..9bb6f16 100644 --- a/listeners/assistant/assistant.py +++ b/listeners/assistant/assistant.py @@ -4,43 +4,16 @@ from slack_bolt import Assistant, BoltContext, Say, SetStatus, SetSuggestedPrompts from slack_bolt.context.get_thread_context import GetThreadContext from slack_sdk import WebClient -from slack_sdk.models.blocks import Block, ContextActionsBlock, FeedbackButtonObject, FeedbackButtonsElement +from slack_sdk.errors import SlackApiError +from ..views.feedback_block import create_feedback_block from ..llm_caller import call_llm + # Refer to https://tools.slack.dev/bolt-python/concepts/assistant/ for more details assistant = Assistant() -def create_feedback_block() -> List[Block]: - """ - Create feedback block with thumbs up/down buttons - - Returns: - Block Kit context_actions block - """ - blocks: List[Block] = [ - ContextActionsBlock( - elements=[ - FeedbackButtonsElement( - action_id="feedback", - positive_button=FeedbackButtonObject( - text="Good Response", - accessibility_label="Submit positive feedback on this response", - value="good-feedback", - ), - negative_button=FeedbackButtonObject( - text="Bad Response", - accessibility_label="Submit negative feedback on this response", - value="bad-feedback", - ), - ) - ] - ) - ] - return blocks - - # This listener is invoked when a human user opened an assistant thread @assistant.thread_started def start_assistant_thread( @@ -86,6 +59,7 @@ def start_assistant_thread( def respond_in_assistant_thread( client: WebClient, context: BoltContext, + get_thread_context: GetThreadContext, logger: logging.Logger, payload: dict, say: Say, @@ -93,10 +67,13 @@ def respond_in_assistant_thread( ): try: channel_id = payload["channel"] + team_id = payload["team"] thread_ts = payload["thread_ts"] + user_id = payload["user"] + user_message = payload["text"] set_status( - status="Drafting...", + status="thinking...", loading_messages=[ "Teaching the hamsters to type faster…", "Untangling the internet cables…", @@ -106,21 +83,45 @@ def respond_in_assistant_thread( ], ) - replies = client.conversations_replies( - channel=context.channel_id, - ts=context.thread_ts, - oldest=context.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"]}) + 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 + 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}] + + else: + replies = client.conversations_replies( + channel=context.channel_id, + ts=context.thread_ts, + oldest=context.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) stream_response = client.chat_startStream( channel=channel_id, + recipient_team_id=team_id, + recipient_user_id=user_id, thread_ts=thread_ts, ) stream_ts = stream_response["ts"] diff --git a/listeners/events/__init__.py b/listeners/events/__init__.py index a2029af..d962ec5 100644 --- a/listeners/events/__init__.py +++ b/listeners/events/__init__.py @@ -1,33 +1,6 @@ -# This sample app repository contains event listener code to help developers understand what's happening under the hood. -# We recommend using assistant middleware instead of these event listeners. -# For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. - -from typing import Dict, Any - from slack_bolt import App -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 +from .app_mentioned import app_mentioned_callback def register(app: App): - 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 + app.event("app_mention")(app_mentioned_callback) diff --git a/listeners/events/app_mentioned.py b/listeners/events/app_mentioned.py new file mode 100644 index 0000000..e9876ba --- /dev/null +++ b/listeners/events/app_mentioned.py @@ -0,0 +1,56 @@ +from logging import Logger +from slack_sdk import WebClient +from slack_bolt import Say + +from ..llm_caller import call_llm +from ..views.feedback_block import create_feedback_block + +""" +Handles the event when the app is mentioned in a Slack conversation +and generates an AI response. +""" + + +def app_mentioned_callback(client: WebClient, event: dict, logger: Logger, say: Say): + try: + channel_id = event.get("channel") + team_id = event.get("team") + text = event.get("text") + thread_ts = event.get("thread_ts") or event.get("ts") + user_id = event.get("user") + + client.assistant_threads_setStatus( + channel_id=channel_id, + thread_ts=thread_ts, + status="thinking...", + 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…", + ], + ) + + returned_message = call_llm([{"role": "user", "content": text}]) + + stream_response = client.chat_startStream( + channel=channel_id, recipient_team_id=team_id, recipient_user_id=user_id, thread_ts=thread_ts + ) + + stream_ts = stream_response["ts"] + + # Loop over OpenAI response stream + # https://platform.openai.com/docs/api-reference/responses/create + 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 + + feedback_block = create_feedback_block() + client.chat_stopStream(channel=channel_id, ts=stream_ts, blocks=feedback_block) + + except Exception as e: + logger.exception(f"Failed to handle a user message event: {e}") + say(f":warning: Something went wrong! ({e})") diff --git a/listeners/events/assistant_thread_started.py b/listeners/events/assistant_thread_started.py deleted file mode 100644 index 0d5abc6..0000000 --- a/listeners/events/assistant_thread_started.py +++ /dev/null @@ -1,68 +0,0 @@ -# This sample app repository contains event listener code to help developers understand what's happening under the hood. -# We recommend using assistant middleware instead of these event listeners. -# For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. - -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 deleted file mode 100644 index 4541f20..0000000 --- a/listeners/events/asssistant_thread_context_changed.py +++ /dev/null @@ -1,23 +0,0 @@ -# This sample app repository contains event listener code to help developers understand what's happening under the hood. -# We recommend using assistant middleware instead of these event listeners. -# For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. - -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/thread_context_store.py b/listeners/events/thread_context_store.py deleted file mode 100644 index 282cdfb..0000000 --- a/listeners/events/thread_context_store.py +++ /dev/null @@ -1,66 +0,0 @@ -# This sample app repository contains event listener code to help developers understand what's happening under the hood. -# We recommend using assistant middleware instead of these event listeners. -# For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. - -from typing import Optional -from slack_sdk import WebClient -from slack_bolt import BoltContext - - -def _find_parent_message( - *, - context: BoltContext, - client: WebClient, - channel_id: str, - thread_ts: str, -) -> 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 deleted file mode 100644 index 3d3b5cf..0000000 --- a/listeners/events/user_message.py +++ /dev/null @@ -1,92 +0,0 @@ -# This sample app repository contains event listener code to help developers understand what's happening under the hood. -# We recommend using assistant middleware instead of these event listeners. -# For more details, refer to https://tools.slack.dev/bolt-python/concepts/assistant/. - -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 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) - 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/views/feedback_block.py b/listeners/views/feedback_block.py new file mode 100644 index 0000000..2064490 --- /dev/null +++ b/listeners/views/feedback_block.py @@ -0,0 +1,31 @@ +from typing import List +from slack_sdk.models.blocks import Block, ContextActionsBlock, FeedbackButtonsElement, FeedbackButtonObject + + +def create_feedback_block() -> List[Block]: + """ + Create feedback block with thumbs up/down buttons + + Returns: + Block Kit context_actions block + """ + blocks: List[Block] = [ + ContextActionsBlock( + elements=[ + FeedbackButtonsElement( + action_id="feedback", + positive_button=FeedbackButtonObject( + text="Good Response", + accessibility_label="Submit positive feedback on this response", + value="good-feedback", + ), + negative_button=FeedbackButtonObject( + text="Bad Response", + accessibility_label="Submit negative feedback on this response", + value="bad-feedback", + ), + ) + ] + ) + ] + return blocks diff --git a/manifest.json b/manifest.json index 9c8bdad..60729ff 100644 --- a/manifest.json +++ b/manifest.json @@ -20,12 +20,15 @@ "oauth_config": { "scopes": { "bot": [ + "app_mentions:read", "assistant:write", "channels:join", "im:history", "channels:history", + "channels:read", "groups:history", - "chat:write" + "chat:write", + "users:read" ] } }, @@ -34,7 +37,9 @@ "bot_events": [ "assistant_thread_context_changed", "assistant_thread_started", - "message.im" + "message.im", + "app_mention", + "message.channels" ] }, "interactivity": {