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)