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:
committed by
Teknium
parent
45bfcb9e71
commit
778fd1898e
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user