From d6f7f8e475adfb90d506ad877137668d08dee04a Mon Sep 17 00:00:00 2001 From: Andriy Kogan Date: Sat, 18 Jan 2025 01:53:02 +0100 Subject: [PATCH] Slack Integration --- .env.dev | 6 +- Pipfile | 1 + Pipfile.lock | 17 +++- docs/agent/slack.md | 101 ++++++++++++++++++++ src/core/config.py | 5 + src/tools/slack.py | 169 +++++++++++++++++++++++++++++++++ tests/tools/test_slack.py | 190 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 docs/agent/slack.md create mode 100644 src/tools/slack.py create mode 100644 tests/tools/test_slack.py diff --git a/.env.dev b/.env.dev index 8f92ff9..ecbb3e3 100644 --- a/.env.dev +++ b/.env.dev @@ -70,4 +70,8 @@ SHOPIFY_PASSWORD= SHOPIFY_STORE_NAME= # Tavily settings -TAVILY_API_KEY= \ No newline at end of file +TAVILY_API_KEY= + +# Slack settings +SLACK_BOT_TOKEN= +SLACK_APP_TOKEN= \ No newline at end of file diff --git a/Pipfile b/Pipfile index 380d4ea..dd451e3 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,7 @@ python-telegram-bot = "*" telegramify-markdown = "*" tweepy = "*" discord = "*" +slack-sdk = "*" google-auth-oauthlib = "*" google-auth = "*" google-api-python-client = "*" diff --git a/Pipfile.lock b/Pipfile.lock index ca24275..7986fef 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "bad74e75b81fb21d31025e9f1ea36df0cb84ee75920815aba9f79c57f07a13e9" + "sha256": "955085c34e05a694625b60b4e1b771b1aaea2eb8f1da4d4203041dec9748d9aa" }, "pipfile-spec": 6, "requires": { @@ -125,12 +125,12 @@ }, "anthropic": { "hashes": [ - "sha256:06801f01d317a431d883230024318d48981758058bf7e079f33fb11f64b5a5c1", - "sha256:f748a703f77b3244975e1aace3a935840dc653a4714fb6bba644f97cc76847b4" + "sha256:20759c25cd0f4072eb966b0180a41c061c156473bbb674da6a3f1e92e1ad78f8", + "sha256:c7f13e4b7b515ac4a3111142310b214527c0fc561485e5bc9b582e49fe3adba2" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==0.43.0" + "version": "==0.43.1" }, "anyio": { "hashes": [ @@ -2734,6 +2734,15 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.17.0" }, + "slack-sdk": { + "hashes": [ + "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa", + "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==3.34.0" + }, "sniffio": { "hashes": [ "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", diff --git a/docs/agent/slack.md b/docs/agent/slack.md new file mode 100644 index 0000000..45dd41b --- /dev/null +++ b/docs/agent/slack.md @@ -0,0 +1,101 @@ +# Slack Integration + +## Setup + +1. Create a Slack App + - Go to [Slack API Apps page](https://api.slack.com/apps) + - Click "Create New App" > "From scratch" + - Choose an app name and workspace + - Save the app configuration + +2. Configure Bot Token and Permissions + - Navigate to "OAuth & Permissions" in your app settings + - Under "Scopes", add these Bot Token Scopes: + - `chat:write` (Send messages) + - `channels:history` (View messages in channels) + - `channels:read` (View basic channel info) + - `im:history` (View direct messages) + - `users:read` (View basic user info) + - Click "Install to Workspace" + - Copy the "Bot User OAuth Token" + +3. Configure Environment Variables + Add these to your `.env` file: + ```bash + SLACK_BOT_TOKEN=xoxb-your-bot-token-here + SLACK_APP_TOKEN=xapp-your-app-token-here + SLACK_SIGNING_SECRET=your-signing-secret-here + ``` + +### Basic Setup +```python +from src.tools.slack import SlackClient + +# Initialize Slack client +slack = SlackClient() + +# Send a message to a channel +response = slack.send_message( + channel="#general", + text="Hello from your AI assistant!" +) + +# Listen for messages +@slack.event("message") +def handle_message(event): + channel = event["channel"] + text = event["text"] + slack.send_message(channel=channel, text=f"Received: {text}") +``` + +## Features +- Real-time message handling with event subscriptions +- Send and receive messages in channels and DMs +- Process message threads and replies +- Support for rich message formatting and blocks +- Message history and context management +- User and channel information retrieval +- Efficient caching of API client + +## TODOs for Future Enhancements: +- Add support for Slack modals and interactive components +- Implement slash commands +- Add support for message reactions +- Implement file management features +- Add support for user presence tracking +- Implement workspace analytics +- Add support for app home customization +- Implement message scheduling features +- Manage interactive components such as buttons and menus +- Enhance error handling and retry mechanisms for API interactions + +## Reference +For implementation details, see: `src/tools/slack.py` + +The implementation uses the official Slack Bolt Framework. For more information, refer to: +- [Slack API Documentation](https://api.slack.com/docs) +- [Slack Bolt Python Framework](https://slack.dev/bolt-python/concepts) + +### Bot Memory +The Slack integration maintains conversation history through a message store that: +- Tracks message threads and their context +- Stores recent interactions per channel/user +- Maintains conversation state for ongoing dialogues +- Implements memory cleanup for older messages +- Supports context retrieval for follow-up responses + +Example of history usage: +```python +# Access conversation history +history = slack.get_conversation_history(channel_id) + +# Get context for a specific thread +thread_context = slack.get_thread_context(thread_ts) + +# Store custom context +slack.store_context( + channel_id=channel, + thread_ts=thread, + context={"key": "value"} +) +``` diff --git a/src/core/config.py b/src/core/config.py index ca47b60..1f27d41 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -177,6 +177,11 @@ class Settings(BaseSettings): TAVILY_API_KEY: str = "" + # --- Slack settings --- + + SLACK_BOT_TOKEN: str = "" + SLACK_APP_TOKEN: str = "" + # ========================== # Validators # ========================== diff --git a/src/tools/slack.py b/src/tools/slack.py new file mode 100644 index 0000000..bf57f3f --- /dev/null +++ b/src/tools/slack.py @@ -0,0 +1,169 @@ +import asyncio +from datetime import datetime +from typing import Callable, Dict, List, Optional + +from slack_sdk.errors import SlackApiError +from slack_sdk.socket_mode.aiohttp import SocketModeClient +from slack_sdk.socket_mode.async_listeners import AsyncSocketModeRequestListener +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.web.async_client import AsyncWebClient + +from src.core.config import settings + + +class SlackIntegration: + def __init__( + self, bot_token: str = settings.SLACK_BOT_TOKEN, app_token: str = settings.SLACK_APP_TOKEN + ): + """Initialize the Slack integration with both bot and app tokens. + + Args: + bot_token: The bot user OAuth token, defaults to settings.SLACK_BOT_TOKEN + app_token: The app-level token starting with 'xapp-', defaults to settings.SLACK_APP_TOKEN + """ + # Initialize with default SSL context instead of bool + ssl_context = None + + self.web_client = AsyncWebClient(token=bot_token, ssl=ssl_context) + self.socket_client = SocketModeClient(app_token=app_token, web_client=self.web_client) + self.message_callback: Optional[Callable] = None + + # Message storage (Bot's memory) + self.message_history: List[Dict] = [] + self.max_history_size = 1000 # Adjust this value as needed + + async def connect(self) -> None: + """Establish connection to Slack""" + try: + # Test the connection + await self.web_client.auth_test() + print("Successfully connected to Slack!") + except SlackApiError as e: + print(f"Error connecting to Slack: {e.response['error']}") + raise + + async def listen_for_messages(self, callback: Callable) -> None: + """Set up message listening with Socket Mode. + + Args: + callback: Function to call when messages are received + """ + self.message_callback = callback + + # Type-cast the handler to match expected type + + handler: AsyncSocketModeRequestListener = self._handle_socket_message # type: ignore + self.socket_client.socket_mode_request_listeners.append(handler) + + # Start the Socket Mode client + await self.socket_client.connect() + print("Listening for messages...") + + async def _handle_socket_message(self, client: SocketModeClient, req: SocketModeRequest): + """Internal handler for socket mode messages""" + print(f"Received request type: {req.type}") # Debug print + + if req.type == "events_api": + # Acknowledge the request + response = SocketModeResponse(envelope_id=req.envelope_id) + await client.send_socket_mode_response(response) + + # Process the event + event = req.payload["event"] + print(f"Received event type: {event['type']}") # Debug print + + if event["type"] == "message" and "subtype" not in event: + print(f"Processing message: {event['text']}") # Debug print + # Store message in history if it's not from a bot + if "bot_id" not in event: + self.add_to_history(event) + + # Call the callback if set + if self.message_callback: + await self.message_callback(event) + + async def send_message( + self, channel_id: str, message: str, thread_ts: Optional[str] = None + ) -> None: + """Send a message to a Slack channel or thread. + + Args: + channel_id: The channel ID to send the message to + message: The message text to send + thread_ts: Optional thread timestamp to reply in a thread + """ + try: + if thread_ts: + await self.web_client.chat_postMessage( + channel=channel_id, text=message, thread_ts=thread_ts + ) + else: + await self.web_client.chat_postMessage(channel=channel_id, text=message) + except SlackApiError as e: + print(f"Error sending message: {e.response['error']}") + raise + + def add_to_history(self, message_event: Dict) -> None: + """Store a message in the bot's memory. + + Args: + message_event: The Slack message event to store + """ + # Create a message structure record + message_record = { + "text": message_event["text"], + "user": message_event["user"], + "channel": message_event["channel"], + "timestamp": message_event["ts"], + "thread_ts": message_event.get("thread_ts"), + "time": datetime.now().isoformat(), + } + + # Add to history + self.message_history.append(message_record) + + # Maintain size + if len(self.message_history) > self.max_history_size: + self.message_history.pop(0) # Remove oldest message + + def get_user_message_history(self, user_id: str) -> List[Dict]: + """Get all messages from a specific user. + + Args: + user_id: The Slack user ID to filter messages for + + Returns: + List of message records from the specified user + """ + return [msg for msg in self.message_history if msg["user"] == user_id] + + def get_channel_history(self, channel_id: str) -> List[Dict]: + """Get all messages from a specific channel. + + Args: + channel_id: The Slack channel ID to filter messages for + + Returns: + List of message records from the specified channel + """ + return [msg for msg in self.message_history if msg["channel"] == channel_id] + + async def close(self): + """Close the Slack connection and cleanup resources""" + # First disconnect the socket client + if hasattr(self, "socket_client") and self.socket_client: + await self.socket_client.disconnect() + # Cancel any pending tasks + for task in asyncio.all_tasks(): + if "process_messages" in str(task): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # Then close the web client session + if hasattr(self, "web_client") and self.web_client: + if hasattr(self.web_client, "session") and self.web_client.session: + await self.web_client.session.close() diff --git a/tests/tools/test_slack.py b/tests/tools/test_slack.py new file mode 100644 index 0000000..3a3c14d --- /dev/null +++ b/tests/tools/test_slack.py @@ -0,0 +1,190 @@ +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from loguru import logger + +from src.tools.slack import SlackIntegration + + +@pytest.fixture(scope="session") +def event_loop(): + """Create an instance of the default event loop for each test case.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def mock_logger(monkeypatch): + """Mock logger for testing.""" + mock_debug = Mock() + mock_error = Mock() + monkeypatch.setattr(logger, "debug", mock_debug) + monkeypatch.setattr(logger, "error", mock_error) + return mock_debug, mock_error + + +@pytest.fixture +async def mock_slack(): + """Fixture to create a SlackIntegration instance with mocked clients""" + with ( + patch("slack_sdk.web.async_client.AsyncWebClient"), + patch("slack_sdk.socket_mode.aiohttp.SocketModeClient"), + ): + # Create instance with dummy tokens + slack = SlackIntegration(bot_token="xoxb-dummy-bot-token", app_token="xapp-dummy-app-token") + + # Setup mock web client + slack.web_client = AsyncMock() + # Setup mock socket client + slack.socket_client = Mock() + slack.socket_client.connect = AsyncMock() + slack.socket_client.disconnect = AsyncMock() + + yield slack + + +@pytest.mark.asyncio +async def test_initialization(mock_slack, mock_logger): + """Test successful client initialization""" + mock_debug, mock_error = mock_logger + mock_slack.web_client.auth_test = AsyncMock(return_value={"ok": True}) + + await mock_slack.connect() + mock_slack.web_client.auth_test.assert_called_once() + mock_error.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_message(mock_slack, mock_logger): + """Test sending messages to channels""" + mock_debug, mock_error = mock_logger + mock_slack.web_client.chat_postMessage = AsyncMock( + return_value={"ok": True, "ts": "1234567890.123456"} + ) + + await mock_slack.send_message("C123456", "Test message") + + mock_slack.web_client.chat_postMessage.assert_called_once_with( + channel="C123456", text="Test message" + ) + mock_error.assert_not_called() + + +@pytest.mark.asyncio +async def test_message_handling(mock_slack, mock_logger): + """Test receiving and processing messages""" + mock_debug, mock_error = mock_logger + + # Create a mock callback + callback_mock = AsyncMock() + await mock_slack.listen_for_messages(callback_mock) + + # Simulate receiving a message + test_event = { + "type": "message", + "text": "Hello, bot!", + "user": "U123456", + "channel": "C123456", + "ts": "1234567890.123456", + } + + # Create mock request + mock_request = Mock() + mock_request.type = "events_api" + mock_request.envelope_id = "test-envelope" + mock_request.payload = {"event": test_event} + + # Create mock client + mock_client = Mock() + mock_client.send_socket_mode_response = AsyncMock() + + # Process the mock message + await mock_slack._handle_socket_message(mock_client, mock_request) + + # Verify callback was called with the event + callback_mock.assert_called_once_with(test_event) + mock_error.assert_not_called() + + +def test_add_to_history(mock_slack, mock_logger): + """Test adding messages to history""" + mock_debug, mock_error = mock_logger + test_message = { + "text": "Test message", + "user": "U123456", + "channel": "C123456", + "ts": "1234567890.123456", + } + + mock_slack.add_to_history(test_message) + + assert len(mock_slack.message_history) == 1 + assert mock_slack.message_history[0]["text"] == "Test message" + assert mock_slack.message_history[0]["user"] == "U123456" + mock_error.assert_not_called() + + +def test_get_user_history(mock_slack, mock_logger): + """Test retrieving user message history""" + mock_debug, mock_error = mock_logger + messages = [ + {"text": "User1 message", "user": "U111", "channel": "C123", "ts": "1234567890.123456"}, + {"text": "User2 message", "user": "U222", "channel": "C123", "ts": "1234567890.123457"}, + ] + + for msg in messages: + mock_slack.add_to_history(msg) + + user_history = mock_slack.get_user_message_history("U111") + assert len(user_history) == 1 + assert user_history[0]["text"] == "User1 message" + mock_error.assert_not_called() + + +def test_get_channel_history(mock_slack, mock_logger): + """Test retrieving channel message history""" + mock_debug, mock_error = mock_logger + messages = [ + {"text": "Channel1 message", "user": "U111", "channel": "C111", "ts": "1234567890.123456"}, + {"text": "Channel2 message", "user": "U111", "channel": "C222", "ts": "1234567890.123457"}, + ] + + for msg in messages: + mock_slack.add_to_history(msg) + + channel_history = mock_slack.get_channel_history("C111") + assert len(channel_history) == 1 + assert channel_history[0]["text"] == "Channel1 message" + mock_error.assert_not_called() + + +@pytest.mark.asyncio +async def test_close(mock_slack, mock_logger): + """Test closing the Slack connection""" + mock_debug, mock_error = mock_logger + + await mock_slack.close() + + mock_slack.socket_client.disconnect.assert_called_once() + mock_error.assert_not_called() + + +@pytest.mark.asyncio +async def test_close_cleanup(mock_slack, mock_logger): + """Test proper cleanup during Slack connection closure""" + mock_debug, mock_error = mock_logger + + # Setup mock session for web client cleanup + mock_session = AsyncMock() + mock_slack.web_client.session = mock_session + mock_session.close = AsyncMock() + + # Call close + await mock_slack.close() + + # Verify both clients are properly cleaned up + mock_slack.socket_client.disconnect.assert_called_once() + mock_session.close.assert_called_once() + mock_error.assert_not_called()