fix(slack): surface attachment access diagnostics

Translate Slack attachment failures into actionable user-facing notices
instead of generic download errors. When a scope/auth/permission issue
breaks attachment processing, the user sees:

  [Slack attachment notice]
  - Slack attachment access failed for photo.jpg. Missing scope:
    files:read. Update the Slack app scopes/settings and reinstall
    the app to the workspace.

Two helpers do the translation:

  _describe_slack_api_error — handles SlackApiError responses
    (missing_scope, invalid_auth, file_not_found, access_denied, etc.)

  _describe_slack_download_failure — handles httpx.HTTPStatusError
    (401/403/404) and Slack-returns-HTML-sign-in fallbacks

Wired into three existing call sites:
 - the Slack Connect files.info path (PR #11111) so scope errors
   surface instead of being logged as generic "files.info failed"
 - the image, audio, and document download paths so 401/403 and
   HTML-body responses translate into actionable notices

Adjustment from original PR: dropped _probe_slack_file_access_issue,
the proactive pre-download files.info probe. It added one extra
Slack API call per attachment even on healthy ones, and overlapped
with the existing files.info call from PR #11111. The post-failure
translation path covers the same user-facing diagnostic value
without the per-message tax.

Also documents files:read scope more prominently in the Slack setup
guide and troubleshooting table.

Contributed back from https://github.com/xinbenlv/zn-hermes-agent.

Closes #7015.
Co-authored-by: xinbenlv <zzn+pa@zzn.im>
This commit is contained in:
Zainan Victor Zhou
2026-04-26 12:47:10 -07:00
committed by Teknium
parent 45bfcb9e71
commit 778fd1898e
4 changed files with 164 additions and 12 deletions

View File

@@ -15,7 +15,7 @@ import os
import re
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Any, Tuple
from typing import Dict, Optional, Any, Tuple, List
try:
from slack_bolt.async_app import AsyncApp
@@ -121,6 +121,63 @@ class SlackAdapter(BasePlatformAdapter):
# clear them (chat_id → thread_ts).
self._active_status_threads: Dict[str, str] = {}
def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Convert Slack API auth/permission failures into actionable user-facing text."""
if response is None or not hasattr(response, "get"):
return None
error = str(response.get("error", "") or "").strip()
if not error:
return None
file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment")
needed = str(response.get("needed", "") or "").strip()
provided = str(response.get("provided", "") or "").strip()
reinstall_hint = " Update the Slack app scopes/settings and reinstall the app to the workspace."
provided_hint = f" Current bot scopes: {provided}." if provided else ""
if error == "missing_scope":
needed_hint = f"Missing scope: {needed}." if needed else "Missing required Slack scope."
return f"Slack attachment access failed for {file_label}. {needed_hint}{provided_hint}{reinstall_hint}"
if error in {"not_authed", "invalid_auth", "account_inactive", "token_revoked"}:
return f"Slack attachment access failed for {file_label} because the bot token is not authorized ({error}). Refresh the token/reinstall the app."
if error in {"file_not_found", "file_deleted"}:
return f"Slack attachment {file_label} is no longer available ({error})."
if error in {"access_denied", "file_access_denied", "no_permission", "not_allowed_token_type", "restricted_action"}:
return f"Slack attachment access failed for {file_label} because the bot does not have permission ({error}). Check workspace permissions/scopes and reinstall if needed."
return None
def _describe_slack_download_failure(self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Translate Slack download exceptions into user-facing attachment diagnostics."""
file_label = str((file_obj or {}).get("name") or (file_obj or {}).get("id") or "this attachment")
response = getattr(exc, "response", None)
api_detail = self._describe_slack_api_error(response, file_obj=file_obj)
if api_detail:
return api_detail
try:
import httpx
except Exception: # pragma: no cover
httpx = None
if httpx is not None and isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code
if status == 401:
return f"Slack attachment access failed for {file_label} with HTTP 401. The bot token is not authorized for this file."
if status == 403:
return f"Slack attachment access failed for {file_label} with HTTP 403. The bot likely lacks permission or scope to read this file."
if status == 404:
return f"Slack attachment {file_label} returned HTTP 404 and is no longer reachable."
message = str(exc)
if "Slack returned HTML instead of media" in message or "non-image data" in message:
return (
f"Slack attachment access failed for {file_label}: Slack returned an HTML/login or non-media response. "
"This usually means a scope, auth, or file-permission problem."
)
return None
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
if not SLACK_AVAILABLE:
@@ -1193,6 +1250,7 @@ class SlackAdapter(BasePlatformAdapter):
# Handle file attachments
media_urls = []
media_types = []
attachment_notices: List[str] = []
files = event.get("files", [])
for f in files:
# Slack Connect channels return stub file objects with
@@ -1209,13 +1267,24 @@ class SlackAdapter(BasePlatformAdapter):
if info_resp.get("ok"):
f = info_resp["file"]
else:
logger.warning(
"[Slack] files.info failed for %s: %s",
file_id, info_resp.get("error"),
)
detail = self._describe_slack_api_error(info_resp, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning(
"[Slack] files.info failed for %s: %s",
file_id, info_resp.get("error"),
)
continue
except Exception as e:
logger.warning("[Slack] files.info error for %s: %s", file_id, e, exc_info=True)
response = getattr(e, "response", None)
detail = self._describe_slack_api_error(response, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] files.info error for %s: %s", file_id, e, exc_info=True)
continue
mimetype = f.get("mimetype", "unknown")
@@ -1231,7 +1300,12 @@ class SlackAdapter(BasePlatformAdapter):
media_types.append(mimetype)
msg_type = MessageType.PHOTO
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True)
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] Failed to cache image from %s: %s", url, e, exc_info=True)
elif mimetype.startswith("audio/") and url:
try:
ext = "." + mimetype.split("/")[-1].split(";")[0]
@@ -1242,7 +1316,12 @@ class SlackAdapter(BasePlatformAdapter):
media_types.append(mimetype)
msg_type = MessageType.VOICE
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True)
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] Failed to cache audio from %s: %s", url, e, exc_info=True)
elif url:
# Try to handle as a document attachment
try:
@@ -1294,7 +1373,16 @@ class SlackAdapter(BasePlatformAdapter):
pass # Binary content, skip injection
except Exception as e: # pragma: no cover - defensive logging
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
detail = self._describe_slack_download_failure(e, file_obj=f)
if detail:
attachment_notices.append(detail)
logger.warning("[Slack] %s", detail)
else:
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
if attachment_notices:
notice_block = "[Slack attachment notice]\n" + "\n".join(f"- {n}" for n in attachment_notices)
text = f"{notice_block}\n\n{text}" if text else notice_block
# Resolve user display name (cached after first lookup)
user_name = await self._resolve_user_name(user_id, chat_id=channel_id)

View File

@@ -540,7 +540,7 @@ from gateway.config import Platform, PlatformConfig # noqa: E402
def _make_slack_adapter():
config = PlatformConfig(enabled=True, token="xoxb-fake-token")
config = PlatformConfig(enabled=True, token="***")
adapter = SlackAdapter(config)
adapter._app = MagicMock()
adapter._app.client = AsyncMock()
@@ -549,6 +549,39 @@ def _make_slack_adapter():
return adapter
# ---------------------------------------------------------------------------
# SlackAdapter diagnostics helpers
# ---------------------------------------------------------------------------
class TestSlackAttachmentDiagnostics:
def test_missing_scope_error_returns_actionable_notice(self):
"""_describe_slack_api_error translates a missing_scope response into
a user-facing notice mentioning the needed scope and the reinstall
step. This is the helper used by every files.info call site (Slack
Connect stubs + post-download failures) to surface scope problems
without making an extra probe call per attachment.
"""
adapter = _make_slack_adapter()
response = {
"error": "missing_scope",
"needed": "files:read",
"provided": "chat:write,files:write",
}
detail = adapter._describe_slack_api_error(response, file_obj={"id": "F123", "name": "photo.jpg"})
assert detail is not None
assert "files:read" in detail
assert "reinstall" in detail.lower()
assert "chat:write,files:write" in detail
def test_download_failure_403_returns_permission_notice(self):
adapter = _make_slack_adapter()
exc = _make_http_status_error(403)
detail = adapter._describe_slack_download_failure(exc, file_obj={"name": "report.pdf"})
assert "403" in detail
assert "permission or scope" in detail
# ---------------------------------------------------------------------------
# SlackAdapter._download_slack_file
# ---------------------------------------------------------------------------

View File

@@ -511,6 +511,35 @@ class TestIncomingDocumentHandling:
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.message_type == MessageType.PHOTO
@pytest.mark.asyncio
async def test_download_failure_is_surfaced_in_message_text(self, adapter):
"""Attachment download failures (401/403/HTML-body/etc.) should be
translated into a user-facing `[Slack attachment notice]` block so
the agent can tell the user what to fix (e.g. missing files:read
scope). No proactive files.info probe is made — the diagnostic
runs only when the download actually fails.
"""
import httpx
req = httpx.Request("GET", "https://files.slack.com/photo.jpg")
resp = httpx.Response(403, request=req)
with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl:
dl.side_effect = httpx.HTTPStatusError("403", request=req, response=resp)
event = self._make_event(text="what's in this?", files=[{
"id": "F123",
"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.TEXT
assert "[Slack attachment notice]" in msg_event.text
assert "403" in msg_event.text
assert "what's in this?" in msg_event.text
# ---------------------------------------------------------------------------
# TestMessageRouting

View File

@@ -82,7 +82,8 @@ Navigate to **Features → OAuth & Permissions** in the sidebar. Scroll to **Sco
:::caution Missing scopes = missing features
Without `channels:history` and `groups:history`, the bot **will not receive messages in channels** —
it will only work in DMs. These are the most commonly missed scopes.
it will only work in DMs. Without `files:read`, Hermes can chat but **cannot reliably read user-uploaded attachments**.
These are the most commonly missed scopes.
:::
**Optional scopes:**
@@ -520,7 +521,8 @@ Keys are Slack channel IDs (find them via channel details → "About" → scroll
| "Sending messages to this app has been turned off" in DMs | Enable the **Messages Tab** in App Home settings (see Step 5) |
| "not_authed" or "invalid_auth" errors | Regenerate your Bot Token and App Token, update `.env` |
| Bot responds but can't post in a channel | Invite the bot to the channel with `/invite @Hermes Agent` |
| "missing_scope" error | Add the required scope in OAuth & Permissions, then **reinstall** the app |
| Bot can chat but can't read uploaded images/files | Add `files:read`, then **reinstall** the app. Hermes now surfaces attachment access diagnostics in-chat when Slack returns scope/auth/permission failures. |
| `missing_scope` error | Add the required scope in OAuth & Permissions, then **reinstall** the app |
| Socket disconnects frequently | Check your network; Bolt auto-reconnects but unstable connections cause lag |
| Changed scopes/events but nothing changed | You **must reinstall** the app to your workspace after any scope or event subscription change |