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:
Xubin Ren
2026-04-27 07:11:11 +00:00
parent 7dcf83e389
commit 620d9e4f31
3 changed files with 132 additions and 7 deletions
+2 -2
View File
@@ -434,12 +434,12 @@ Uses **Socket Mode** — no public URL required.
**2. Configure the app** **2. Configure the app**
- **Socket Mode**: Toggle ON → Generate an **App-Level Token** with `connections:write` scope → copy it (`xapp-...`) - **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 - **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"** - **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-...`) - **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** **3. Configure nanobot**
+69 -4
View File
@@ -2,8 +2,10 @@
import asyncio import asyncio
import re import re
from pathlib import Path
from typing import Any from typing import Any
import httpx
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
from slack_sdk.socket_mode.request import SocketModeRequest 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.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base 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): class SlackDMConfig(Base):
@@ -48,6 +51,8 @@ class SlackConfig(Base):
SLACK_MAX_MESSAGE_LEN = 39_000 # Slack API allows ~40k; leave margin 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): class SlackChannel(BaseChannel):
@@ -307,8 +312,10 @@ class SlackChannel(BaseChannel):
sender_id = event.get("user") sender_id = event.get("user")
chat_id = event.get("channel") chat_id = event.get("channel")
# Ignore bot/system messages (any subtype = not a normal user message) subtype = event.get("subtype")
if 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 return
if self._bot_user_id and sender_id == self._bot_user_id: if self._bot_user_id and sender_id == self._bot_user_id:
return return
@@ -323,7 +330,7 @@ class SlackChannel(BaseChannel):
logger.debug( logger.debug(
"Slack event: type={} subtype={} user={} channel={} channel_type={} text={}", "Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
event_type, event_type,
event.get("subtype"), subtype,
sender_id, sender_id,
chat_id, chat_id,
event.get("channel_type"), event.get("channel_type"),
@@ -360,6 +367,17 @@ class SlackChannel(BaseChannel):
# Thread-scoped session key for channel/group messages # 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 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("/") is_slash = text.strip().startswith("/")
content = text if is_slash else await self._with_thread_context( content = text if is_slash else await self._with_thread_context(
text, text,
@@ -369,12 +387,17 @@ class SlackChannel(BaseChannel):
raw_thread_ts=raw_thread_ts, raw_thread_ts=raw_thread_ts,
current_ts=event_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: try:
await self._handle_message( await self._handle_message(
sender_id=sender_id, sender_id=sender_id,
chat_id=chat_id, chat_id=chat_id,
content=content, content=content,
media=media_paths,
metadata={ metadata={
"slack": { "slack": {
"event": event, "event": event,
@@ -387,6 +410,48 @@ class SlackChannel(BaseChannel):
except Exception: except Exception:
logger.exception("Error handling Slack message from {}", sender_id) 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: async def _on_block_action(self, client: SocketModeClient, req: SocketModeRequest) -> None:
"""Handle button clicks from ask_user blocks.""" """Handle button clicks from ask_user blocks."""
await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
+61 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import httpx
import pytest import pytest
# Check optional Slack dependencies before running tests # Check optional Slack dependencies before running tests
@@ -31,7 +32,7 @@ class _FakeAsyncWebClient:
self._users_pages: list[dict[str, object]] = [] self._users_pages: list[dict[str, object]] = []
self._open_dm_response: dict[str, object] = {"channel": {"id": "D_OPENED"}} 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, self,
*, *,
channel: str, 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" 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: def test_slack_channel_uses_channel_aware_allow_policy() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus()) channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
assert channel.is_allowed("U1") is True assert channel.is_allowed("U1") is True