diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 11a73461e7..020843d3ac 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -10,6 +10,7 @@ Uses slack-bolt (Python) with Socket Mode for: import asyncio import os +import re from typing import Dict, List, Optional, Any try: @@ -33,6 +34,8 @@ from gateway.platforms.base import ( MessageEvent, MessageType, SendResult, + SUPPORTED_DOCUMENT_TYPES, + cache_document_from_bytes, cache_image_from_url, cache_audio_from_url, ) @@ -96,6 +99,13 @@ class SlackAdapter(BasePlatformAdapter): async def handle_message_event(event, say): await self._handle_slack_message(event) + # Acknowledge app_mention events to prevent Bolt 404 errors. + # The "message" handler above already processes @mentions in + # channels, so this is intentionally a no-op to avoid duplicates. + @self._app.event("app_mention") + async def handle_app_mention(event, say): + pass + # Register slash command handler @self._app.command("/hermes") async def handle_hermes_command(ack, command): @@ -266,6 +276,65 @@ class SlackAdapter(BasePlatformAdapter): except Exception as e: return SendResult(success=False, error=str(e)) + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + """Send a video file to Slack.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + if not os.path.exists(video_path): + return SendResult(success=False, error=f"Video file not found: {video_path}") + + try: + result = await self._app.client.files_upload_v2( + channel=chat_id, + file=video_path, + filename=os.path.basename(video_path), + initial_comment=caption or "", + thread_ts=reply_to, + ) + return SendResult(success=True, raw_response=result) + + except Exception as e: + print(f"[{self.name}] Failed to send video: {e}") + return await super().send_video(chat_id, video_path, caption, reply_to) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + """Send a document/file attachment to Slack.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + if not os.path.exists(file_path): + return SendResult(success=False, error=f"File not found: {file_path}") + + display_name = file_name or os.path.basename(file_path) + + try: + result = await self._app.client.files_upload_v2( + channel=chat_id, + file=file_path, + filename=display_name, + initial_comment=caption or "", + thread_ts=reply_to, + ) + return SendResult(success=True, raw_response=result) + + except Exception as e: + print(f"[{self.name}] Failed to send document: {e}") + return await super().send_document(chat_id, file_path, caption, file_name, reply_to) + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Get information about a Slack channel.""" if not self._app: @@ -347,6 +416,58 @@ class SlackAdapter(BasePlatformAdapter): msg_type = MessageType.VOICE except Exception as e: print(f"[Slack] Failed to cache audio: {e}", flush=True) + elif url: + # Try to handle as a document attachment + try: + original_filename = f.get("name", "") + ext = "" + if original_filename: + _, ext = os.path.splitext(original_filename) + ext = ext.lower() + + # Fallback: reverse-lookup from MIME type + if not ext and mimetype: + mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + ext = mime_to_ext.get(mimetype, "") + + if ext not in SUPPORTED_DOCUMENT_TYPES: + continue # Skip unsupported file types silently + + # Check file size (Slack limit: 20 MB for bots) + file_size = f.get("size", 0) + MAX_DOC_BYTES = 20 * 1024 * 1024 + if not file_size or file_size > MAX_DOC_BYTES: + print(f"[Slack] Document too large or unknown size: {file_size}", flush=True) + continue + + # Download and cache + raw_bytes = await self._download_slack_file_bytes(url) + cached_path = cache_document_from_bytes( + raw_bytes, original_filename or f"document{ext}" + ) + doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + media_urls.append(cached_path) + media_types.append(doc_mime) + msg_type = MessageType.DOCUMENT + print(f"[Slack] Cached user document: {cached_path}", flush=True) + + # Inject text content for .txt/.md files (capped at 100 KB) + MAX_TEXT_INJECT_BYTES = 100 * 1024 + if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + try: + text_content = raw_bytes.decode("utf-8") + display_name = original_filename or f"document{ext}" + display_name = re.sub(r'[^\w.\- ]', '_', display_name) + injection = f"[Content of {display_name}]:\n{text_content}" + if text: + text = f"{injection}\n\n{text}" + else: + text = injection + except UnicodeDecodeError: + pass # Binary content, skip injection + + except Exception as e: + print(f"[Slack] Failed to cache document: {e}", flush=True) # Build source source = self.build_source( @@ -427,3 +548,16 @@ class SlackAdapter(BasePlatformAdapter): else: from gateway.platforms.base import cache_image_from_bytes return cache_image_from_bytes(response.content, ext) + + async def _download_slack_file_bytes(self, url: str) -> bytes: + """Download a Slack file and return raw bytes.""" + import httpx + + bot_token = self.config.token + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + response = await client.get( + url, + headers={"Authorization": f"Bearer {bot_token}"}, + ) + response.raise_for_status() + return response.content diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py new file mode 100644 index 0000000000..efdb62ce49 --- /dev/null +++ b/tests/gateway/test_slack.py @@ -0,0 +1,532 @@ +""" +Tests for Slack platform adapter. + +Covers: app_mention handler, send_document, send_video, + incoming document handling, message routing. + +Note: slack-bolt may not be installed in the test environment. +We mock the slack modules at import time to avoid collection errors. +""" + +import asyncio +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + MessageEvent, + MessageType, + SendResult, + SUPPORTED_DOCUMENT_TYPES, +) + + +# --------------------------------------------------------------------------- +# Mock the slack-bolt package if it's not installed +# --------------------------------------------------------------------------- + +def _ensure_slack_mock(): + """Install mock slack modules so SlackAdapter can be imported.""" + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return # Real library installed + + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock + + slack_sdk = MagicMock() + slack_sdk.web.async_client.AsyncWebClient = MagicMock + + for name, mod in [ + ("slack_bolt", slack_bolt), + ("slack_bolt.async_app", slack_bolt.async_app), + ("slack_bolt.adapter", slack_bolt.adapter), + ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), + ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ("slack_sdk", slack_sdk), + ("slack_sdk.web", slack_sdk.web), + ("slack_sdk.web.async_client", slack_sdk.web.async_client), + ]: + sys.modules.setdefault(name, mod) + + +_ensure_slack_mock() + +# Patch SLACK_AVAILABLE before importing the adapter +import gateway.platforms.slack as _slack_mod +_slack_mod.SLACK_AVAILABLE = True + +from gateway.platforms.slack import SlackAdapter # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def adapter(): + config = PlatformConfig(enabled=True, token="xoxb-fake-token") + a = SlackAdapter(config) + # Mock the Slack app client + a._app = MagicMock() + a._app.client = AsyncMock() + a._bot_user_id = "U_BOT" + a._running = True + # Capture events instead of processing them + a.handle_message = AsyncMock() + return a + + +@pytest.fixture(autouse=True) +def _redirect_cache(tmp_path, monkeypatch): + """Point document cache to tmp_path so tests don't touch ~/.hermes.""" + monkeypatch.setattr( + "gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache" + ) + + +# --------------------------------------------------------------------------- +# TestAppMentionHandler +# --------------------------------------------------------------------------- + +class TestAppMentionHandler: + """Verify that the app_mention event handler is registered.""" + + def test_app_mention_registered_on_connect(self): + """connect() should register both 'message' and 'app_mention' handlers.""" + config = PlatformConfig(enabled=True, token="xoxb-fake") + adapter = SlackAdapter(config) + + # Track which events get registered + registered_events = [] + registered_commands = [] + + mock_app = MagicMock() + + def mock_event(event_type): + def decorator(fn): + registered_events.append(event_type) + return fn + return decorator + + def mock_command(cmd): + def decorator(fn): + registered_commands.append(cmd) + return fn + return decorator + + mock_app.event = mock_event + mock_app.command = mock_command + mock_app.client = AsyncMock() + mock_app.client.auth_test = AsyncMock(return_value={ + "user_id": "U_BOT", + "user": "testbot", + }) + + with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ + patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ + patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ + patch("asyncio.create_task"): + asyncio.get_event_loop().run_until_complete(adapter.connect()) + + assert "message" in registered_events + assert "app_mention" in registered_events + assert "/hermes" in registered_commands + + +# --------------------------------------------------------------------------- +# TestSendDocument +# --------------------------------------------------------------------------- + +class TestSendDocument: + @pytest.mark.asyncio + async def test_send_document_success(self, adapter, tmp_path): + test_file = tmp_path / "report.pdf" + test_file.write_bytes(b"%PDF-1.4 fake content") + + adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) + + result = await adapter.send_document( + chat_id="C123", + file_path=str(test_file), + caption="Here's the report", + ) + + assert result.success + adapter._app.client.files_upload_v2.assert_called_once() + call_kwargs = adapter._app.client.files_upload_v2.call_args[1] + assert call_kwargs["channel"] == "C123" + assert call_kwargs["file"] == str(test_file) + assert call_kwargs["filename"] == "report.pdf" + assert call_kwargs["initial_comment"] == "Here's the report" + + @pytest.mark.asyncio + async def test_send_document_custom_name(self, adapter, tmp_path): + test_file = tmp_path / "data.csv" + test_file.write_bytes(b"a,b,c\n1,2,3") + + adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) + + result = await adapter.send_document( + chat_id="C123", + file_path=str(test_file), + file_name="quarterly-report.csv", + ) + + assert result.success + call_kwargs = adapter._app.client.files_upload_v2.call_args[1] + assert call_kwargs["filename"] == "quarterly-report.csv" + + @pytest.mark.asyncio + async def test_send_document_missing_file(self, adapter): + result = await adapter.send_document( + chat_id="C123", + file_path="/nonexistent/file.pdf", + ) + + assert not result.success + assert "not found" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_document_not_connected(self, adapter): + adapter._app = None + result = await adapter.send_document( + chat_id="C123", + file_path="/some/file.pdf", + ) + + assert not result.success + assert "Not connected" in result.error + + @pytest.mark.asyncio + async def test_send_document_api_error_falls_back(self, adapter, tmp_path): + test_file = tmp_path / "doc.pdf" + test_file.write_bytes(b"content") + + adapter._app.client.files_upload_v2 = AsyncMock( + side_effect=RuntimeError("Slack API error") + ) + + # Should fall back to base class (text message) + result = await adapter.send_document( + chat_id="C123", + file_path=str(test_file), + ) + + # Base class send() is also mocked, so check it was attempted + adapter._app.client.chat_postMessage.assert_called_once() + + @pytest.mark.asyncio + async def test_send_document_with_thread(self, adapter, tmp_path): + test_file = tmp_path / "notes.txt" + test_file.write_bytes(b"some notes") + + adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) + + result = await adapter.send_document( + chat_id="C123", + file_path=str(test_file), + reply_to="1234567890.123456", + ) + + assert result.success + call_kwargs = adapter._app.client.files_upload_v2.call_args[1] + assert call_kwargs["thread_ts"] == "1234567890.123456" + + +# --------------------------------------------------------------------------- +# TestSendVideo +# --------------------------------------------------------------------------- + +class TestSendVideo: + @pytest.mark.asyncio + async def test_send_video_success(self, adapter, tmp_path): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video data") + + adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) + + result = await adapter.send_video( + chat_id="C123", + video_path=str(video), + caption="Check this out", + ) + + assert result.success + call_kwargs = adapter._app.client.files_upload_v2.call_args[1] + assert call_kwargs["filename"] == "clip.mp4" + assert call_kwargs["initial_comment"] == "Check this out" + + @pytest.mark.asyncio + async def test_send_video_missing_file(self, adapter): + result = await adapter.send_video( + chat_id="C123", + video_path="/nonexistent/video.mp4", + ) + + assert not result.success + assert "not found" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_video_not_connected(self, adapter): + adapter._app = None + result = await adapter.send_video( + chat_id="C123", + video_path="/some/video.mp4", + ) + + assert not result.success + assert "Not connected" in result.error + + @pytest.mark.asyncio + async def test_send_video_api_error_falls_back(self, adapter, tmp_path): + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake video") + + adapter._app.client.files_upload_v2 = AsyncMock( + side_effect=RuntimeError("Slack API error") + ) + + # Should fall back to base class (text message) + result = await adapter.send_video( + chat_id="C123", + video_path=str(video), + ) + + adapter._app.client.chat_postMessage.assert_called_once() + + +# --------------------------------------------------------------------------- +# TestIncomingDocumentHandling +# --------------------------------------------------------------------------- + +class TestIncomingDocumentHandling: + def _make_event(self, files=None, text="hello", channel_type="im"): + """Build a mock Slack message event with file attachments.""" + return { + "text": text, + "user": "U_USER", + "channel": "C123", + "channel_type": channel_type, + "ts": "1234567890.000001", + "files": files or [], + } + + @pytest.mark.asyncio + async def test_pdf_document_cached(self, adapter): + """A PDF attachment should be downloaded, cached, and set as DOCUMENT type.""" + pdf_bytes = b"%PDF-1.4 fake content" + + with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + dl.return_value = pdf_bytes + event = self._make_event(files=[{ + "mimetype": "application/pdf", + "name": "report.pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "size": len(pdf_bytes), + }]) + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.message_type == MessageType.DOCUMENT + assert len(msg_event.media_urls) == 1 + assert os.path.exists(msg_event.media_urls[0]) + assert msg_event.media_types == ["application/pdf"] + + @pytest.mark.asyncio + async def test_txt_document_injects_content(self, adapter): + """A .txt file under 100KB should have its content injected into event text.""" + content = b"Hello from a text file" + + with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + dl.return_value = content + event = self._make_event( + text="summarize this", + files=[{ + "mimetype": "text/plain", + "name": "notes.txt", + "url_private_download": "https://files.slack.com/notes.txt", + "size": len(content), + }], + ) + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert "Hello from a text file" in msg_event.text + assert "[Content of notes.txt]" in msg_event.text + assert "summarize this" in msg_event.text + + @pytest.mark.asyncio + async def test_md_document_injects_content(self, adapter): + """A .md file under 100KB should have its content injected.""" + content = b"# Title\nSome markdown content" + + with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + dl.return_value = content + event = self._make_event(files=[{ + "mimetype": "text/markdown", + "name": "readme.md", + "url_private_download": "https://files.slack.com/readme.md", + "size": len(content), + }], text="") + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert "# Title" in msg_event.text + + @pytest.mark.asyncio + async def test_large_txt_not_injected(self, adapter): + """A .txt file over 100KB should be cached but NOT injected.""" + content = b"x" * (200 * 1024) + + with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + dl.return_value = content + event = self._make_event(files=[{ + "mimetype": "text/plain", + "name": "big.txt", + "url_private_download": "https://files.slack.com/big.txt", + "size": len(content), + }], text="") + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert len(msg_event.media_urls) == 1 + assert "[Content of" not in (msg_event.text or "") + + @pytest.mark.asyncio + async def test_unsupported_file_type_skipped(self, adapter): + """A .zip file should be silently skipped.""" + event = self._make_event(files=[{ + "mimetype": "application/zip", + "name": "archive.zip", + "url_private_download": "https://files.slack.com/archive.zip", + "size": 1024, + }]) + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.message_type == MessageType.TEXT + assert len(msg_event.media_urls) == 0 + + @pytest.mark.asyncio + async def test_oversized_document_skipped(self, adapter): + """A document over 20MB should be skipped.""" + event = self._make_event(files=[{ + "mimetype": "application/pdf", + "name": "huge.pdf", + "url_private_download": "https://files.slack.com/huge.pdf", + "size": 25 * 1024 * 1024, + }]) + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert len(msg_event.media_urls) == 0 + + @pytest.mark.asyncio + async def test_document_download_error_handled(self, adapter): + """If document download fails, handler should not crash.""" + with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: + dl.side_effect = RuntimeError("download failed") + event = self._make_event(files=[{ + "mimetype": "application/pdf", + "name": "report.pdf", + "url_private_download": "https://files.slack.com/report.pdf", + "size": 1024, + }]) + await adapter._handle_slack_message(event) + + # Handler should still be called (the exception is caught) + adapter.handle_message.assert_called_once() + + @pytest.mark.asyncio + async def test_image_still_handled(self, adapter): + """Image attachments should still go through the image path, not document.""" + with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl: + dl.return_value = "/tmp/cached_image.jpg" + event = self._make_event(files=[{ + "mimetype": "image/jpeg", + "name": "photo.jpg", + "url_private_download": "https://files.slack.com/photo.jpg", + "size": 1024, + }]) + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.message_type == MessageType.PHOTO + + +# --------------------------------------------------------------------------- +# TestMessageRouting +# --------------------------------------------------------------------------- + +class TestMessageRouting: + @pytest.mark.asyncio + async def test_dm_processed_without_mention(self, adapter): + """DM messages should be processed without requiring a bot mention.""" + event = { + "text": "hello", + "user": "U_USER", + "channel": "D123", + "channel_type": "im", + "ts": "1234567890.000001", + } + await adapter._handle_slack_message(event) + adapter.handle_message.assert_called_once() + + @pytest.mark.asyncio + async def test_channel_message_requires_mention(self, adapter): + """Channel messages without a bot mention should be ignored.""" + event = { + "text": "just talking", + "user": "U_USER", + "channel": "C123", + "channel_type": "channel", + "ts": "1234567890.000001", + } + await adapter._handle_slack_message(event) + adapter.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_channel_mention_strips_bot_id(self, adapter): + """When mentioned in a channel, the bot mention should be stripped.""" + event = { + "text": "<@U_BOT> what's the weather?", + "user": "U_USER", + "channel": "C123", + "channel_type": "channel", + "ts": "1234567890.000001", + } + await adapter._handle_slack_message(event) + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "what's the weather?" + assert "<@U_BOT>" not in msg_event.text + + @pytest.mark.asyncio + async def test_bot_messages_ignored(self, adapter): + """Messages from bots should be ignored.""" + event = { + "text": "bot response", + "bot_id": "B_OTHER", + "channel": "C123", + "channel_type": "im", + "ts": "1234567890.000001", + } + await adapter._handle_slack_message(event) + adapter.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_message_edits_ignored(self, adapter): + """Message edits should be ignored.""" + event = { + "text": "edited message", + "user": "U_USER", + "channel": "C123", + "channel_type": "im", + "ts": "1234567890.000001", + "subtype": "message_changed", + } + await adapter._handle_slack_message(event) + adapter.handle_message.assert_not_called()