feat(slack): fix app_mention 404 + add document/video support
- Register no-op app_mention event handler to suppress Bolt 404 errors. The 'message' handler already processes @mentions in channels, so app_mention is acknowledged without duplicate processing. - Add send_document() for native file attachments (PDFs, CSVs, etc.) via files_upload_v2, matching the pattern from Telegram PR #779. - Add send_video() for native video uploads via files_upload_v2. - Handle incoming document attachments from users: download, cache, and inject text content for .txt/.md files (capped at 100KB), following the same pattern as the Telegram adapter. - Add _download_slack_file_bytes() helper for raw byte downloads. - Add 24 new tests covering all new functionality. Fixes the unhandled app_mention events reported in gateway logs.
This commit is contained in:
parent
c754135965
commit
34e8d088c2
2 changed files with 666 additions and 0 deletions
|
|
@ -10,6 +10,7 @@ Uses slack-bolt (Python) with Socket Mode for:
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -33,6 +34,8 @@ from gateway.platforms.base import (
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
MessageType,
|
MessageType,
|
||||||
SendResult,
|
SendResult,
|
||||||
|
SUPPORTED_DOCUMENT_TYPES,
|
||||||
|
cache_document_from_bytes,
|
||||||
cache_image_from_url,
|
cache_image_from_url,
|
||||||
cache_audio_from_url,
|
cache_audio_from_url,
|
||||||
)
|
)
|
||||||
|
|
@ -96,6 +99,13 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
async def handle_message_event(event, say):
|
async def handle_message_event(event, say):
|
||||||
await self._handle_slack_message(event)
|
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
|
# Register slash command handler
|
||||||
@self._app.command("/hermes")
|
@self._app.command("/hermes")
|
||||||
async def handle_hermes_command(ack, command):
|
async def handle_hermes_command(ack, command):
|
||||||
|
|
@ -266,6 +276,65 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return SendResult(success=False, error=str(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]:
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||||
"""Get information about a Slack channel."""
|
"""Get information about a Slack channel."""
|
||||||
if not self._app:
|
if not self._app:
|
||||||
|
|
@ -347,6 +416,58 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
msg_type = MessageType.VOICE
|
msg_type = MessageType.VOICE
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Slack] Failed to cache audio: {e}", flush=True)
|
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
|
# Build source
|
||||||
source = self.build_source(
|
source = self.build_source(
|
||||||
|
|
@ -427,3 +548,16 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
else:
|
else:
|
||||||
from gateway.platforms.base import cache_image_from_bytes
|
from gateway.platforms.base import cache_image_from_bytes
|
||||||
return cache_image_from_bytes(response.content, ext)
|
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
|
||||||
|
|
|
||||||
532
tests/gateway/test_slack.py
Normal file
532
tests/gateway/test_slack.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue