fix(slack): accept inbound file_share messages without dropping them
Slack inbound events with subtype=file_share were silently dropped, so nanobot never saw messages that included attachments. Allow file_share through, download Slack-private files using the bot token into the local media dir, and pass them to the agent as media paths plus a "[file: name]" / "[image: name]" placeholder in the content. Reject responses that look like Slack's login HTML so an auth page is never saved as if it were the user's file. Document the required files:read scope alongside files:write so installs that read attachments are not quietly missing the permission.
This commit is contained in:
+2
-2
@@ -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**
|
||||
|
||||
|
||||
@@ -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"<!doctype html", b"<html")
|
||||
|
||||
|
||||
class SlackChannel(BaseChannel):
|
||||
@@ -307,8 +312,10 @@ class SlackChannel(BaseChannel):
|
||||
sender_id = event.get("user")
|
||||
chat_id = event.get("channel")
|
||||
|
||||
# Ignore bot/system messages (any subtype = not a normal user message)
|
||||
if event.get("subtype"):
|
||||
subtype = event.get("subtype")
|
||||
# Slack uses subtype=file_share for user messages with attachments.
|
||||
# Ignore other subtypes such as bot_message / message_changed / deleted.
|
||||
if subtype and subtype != "file_share":
|
||||
return
|
||||
if self._bot_user_id and sender_id == self._bot_user_id:
|
||||
return
|
||||
@@ -323,7 +330,7 @@ class SlackChannel(BaseChannel):
|
||||
logger.debug(
|
||||
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
|
||||
event_type,
|
||||
event.get("subtype"),
|
||||
subtype,
|
||||
sender_id,
|
||||
chat_id,
|
||||
event.get("channel_type"),
|
||||
@@ -360,6 +367,17 @@ class SlackChannel(BaseChannel):
|
||||
|
||||
# Thread-scoped session key for channel/group messages
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
|
||||
media_paths: list[str] = []
|
||||
file_markers: list[str] = []
|
||||
for file_info in event.get("files") or []:
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
file_path, marker = await self._download_slack_file(file_info)
|
||||
if file_path:
|
||||
media_paths.append(file_path)
|
||||
if marker:
|
||||
file_markers.append(marker)
|
||||
|
||||
is_slash = text.strip().startswith("/")
|
||||
content = text if is_slash else await self._with_thread_context(
|
||||
text,
|
||||
@@ -369,12 +387,17 @@ class SlackChannel(BaseChannel):
|
||||
raw_thread_ts=raw_thread_ts,
|
||||
current_ts=event_ts,
|
||||
)
|
||||
if file_markers:
|
||||
content = "\n".join(part for part in [content, *file_markers] if part)
|
||||
if not content and not media_paths:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=content,
|
||||
media=media_paths,
|
||||
metadata={
|
||||
"slack": {
|
||||
"event": event,
|
||||
@@ -387,6 +410,48 @@ class SlackChannel(BaseChannel):
|
||||
except Exception:
|
||||
logger.exception("Error handling Slack message from {}", sender_id)
|
||||
|
||||
async def _download_slack_file(self, file_info: dict[str, Any]) -> 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))
|
||||
|
||||
@@ -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"<!doctype html><html><title>Sign in to Slack</title>",
|
||||
)
|
||||
markdown_response = httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/markdown"},
|
||||
content=b"# PR Extraction Guide\n",
|
||||
)
|
||||
|
||||
assert SlackChannel._looks_like_html_download(html_response) is True
|
||||
assert SlackChannel._looks_like_html_download(markdown_response) is False
|
||||
|
||||
|
||||
def test_slack_channel_uses_channel_aware_allow_policy() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
assert channel.is_allowed("U1") is True
|
||||
|
||||
Reference in New Issue
Block a user