diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 0b64e421..75e6f26f 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -434,12 +434,12 @@ Uses **Socket Mode** — no public URL required. **2. Configure the app** - **Socket Mode**: Toggle ON → Generate an **App-Level Token** with `connections:write` scope → copy it (`xapp-...`) -- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`, `files:write`, `channels:history`, `groups:history`, `im:history`, `mpim:history` +- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`, `files:read`, `files:write`, `channels:history`, `groups:history`, `im:history`, `mpim:history` - **Event Subscriptions**: Toggle ON → Subscribe to bot events: `message.im`, `message.channels`, `app_mention` → Save Changes - **App Home**: Scroll to **Show Tabs** → Enable **Messages Tab** → Check **"Allow users to send Slash commands and messages from the messages tab"** - **Install App**: Click **Install to Workspace** → Authorize → copy the **Bot Token** (`xoxb-...`) -> `files:write` is required for images, videos, and other file uploads. If you add it later, reinstall the Slack app to the workspace and restart nanobot so it uses the updated bot token. +> `files:read` is required to read files users send to nanobot. `files:write` is required for nanobot to send images, videos, and other file uploads. If you add either scope later, reinstall the Slack app to the workspace and restart nanobot so it uses the updated bot token. **3. Configure nanobot** diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 5b00ed7e..4c9c25ba 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -2,8 +2,10 @@ import asyncio import re +from pathlib import Path from typing import Any +import httpx from loguru import logger from pydantic import Field from slack_sdk.socket_mode.request import SocketModeRequest @@ -15,8 +17,9 @@ from slackify_markdown import slackify_markdown from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base -from nanobot.utils.helpers import split_message +from nanobot.utils.helpers import safe_filename, split_message class SlackDMConfig(Base): @@ -48,6 +51,8 @@ class SlackConfig(Base): SLACK_MAX_MESSAGE_LEN = 39_000 # Slack API allows ~40k; leave margin +SLACK_DOWNLOAD_TIMEOUT = 30.0 +_HTML_DOWNLOAD_PREFIXES = (b" tuple[str | None, str]: + """Download a Slack private file to the local media directory.""" + file_id = str(file_info.get("id") or "file") + name = str( + file_info.get("name") + or file_info.get("title") + or file_info.get("id") + or "slack-file" + ) + marker_type = "image" if str(file_info.get("mimetype") or "").startswith("image/") else "file" + marker = f"[{marker_type}: {name}]" + url = str(file_info.get("url_private_download") or file_info.get("url_private") or "") + if not url: + return None, f"[{marker_type}: {name}: missing download url]" + if not self.config.bot_token: + return None, f"[{marker_type}: {name}: missing bot token]" + + filename = safe_filename(f"{file_id}_{name}") + path = Path(get_media_dir("slack")) / filename + try: + async with httpx.AsyncClient(timeout=SLACK_DOWNLOAD_TIMEOUT, follow_redirects=True) as client: + response = await client.get( + url, + headers={"Authorization": f"Bearer {self.config.bot_token}"}, + ) + response.raise_for_status() + if self._looks_like_html_download(response): + raise ValueError("Slack returned HTML instead of file content") + path.write_bytes(response.content) + return str(path), marker + except Exception as e: + logger.warning("Failed to download Slack file {}: {}", file_id, e) + return None, f"[{marker_type}: {name}: download failed]" + + @staticmethod + def _looks_like_html_download(response: httpx.Response) -> bool: + content_type = response.headers.get("content-type", "").lower() + if "text/html" in content_type: + return True + preview = response.content[:256].lstrip().lower() + return preview.startswith(_HTML_DOWNLOAD_PREFIXES) + async def _on_block_action(self, client: SocketModeClient, req: SocketModeRequest) -> None: """Handle button clicks from ask_user blocks.""" await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) diff --git a/tests/channels/test_slack_channel.py b/tests/channels/test_slack_channel.py index f3905237..df7a7b56 100644 --- a/tests/channels/test_slack_channel.py +++ b/tests/channels/test_slack_channel.py @@ -3,6 +3,7 @@ from __future__ import annotations from types import SimpleNamespace from unittest.mock import AsyncMock +import httpx import pytest # Check optional Slack dependencies before running tests @@ -31,7 +32,7 @@ class _FakeAsyncWebClient: self._users_pages: list[dict[str, object]] = [] self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}} - async def chat_postMessage( + async def chat_postMessage( # noqa: N802 - mirrors Slack SDK method name self, *, channel: str, @@ -459,6 +460,65 @@ async def test_slack_slash_command_skips_thread_context() -> None: assert channel._handle_message.await_args.kwargs["content"] == "/restart" +@pytest.mark.asyncio +async def test_slack_file_share_downloads_media_and_reaches_agent() -> None: + channel = SlackChannel(SlackConfig(enabled=True, bot_token="xoxb-test"), MessageBus()) + channel._bot_user_id = "UBOT" + channel._web_client = _FakeAsyncWebClient() + channel._handle_message = AsyncMock() # type: ignore[method-assign] + channel._download_slack_file = AsyncMock( # type: ignore[method-assign] + return_value=("/tmp/report.pdf", "[file: report.pdf]") + ) + client = SimpleNamespace(send_socket_mode_response=AsyncMock()) + req = SimpleNamespace( + type="events_api", + envelope_id="env-file", + payload={ + "event": { + "type": "message", + "subtype": "file_share", + "user": "U1", + "channel": "D123", + "channel_type": "im", + "text": "please read this", + "ts": "1700000000.000100", + "files": [ + { + "id": "F123", + "name": "report.pdf", + "mimetype": "application/pdf", + "url_private_download": "https://files.slack.com/report.pdf", + } + ], + } + }, + ) + + await channel._on_socket_request(client, req) + + channel._download_slack_file.assert_awaited_once() + channel._handle_message.assert_awaited_once() + kwargs = channel._handle_message.await_args.kwargs + assert kwargs["content"] == "please read this\n[file: report.pdf]" + assert kwargs["media"] == ["/tmp/report.pdf"] + + +def test_slack_download_rejects_login_html() -> None: + html_response = httpx.Response( + 200, + headers={"content-type": "text/html; charset=utf-8"}, + content=b"