Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 114 additions & 4 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
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
Expand Down Expand Up @@ -115,6 +115,91 @@ def __init__(self, config: PlatformConfig):
self._thread_context_cache: Dict[str, _ThreadContextCache] = {}
self._THREAD_CACHE_TTL = 60.0

def _get_team_client(self, team_id: str = "") -> Optional[Any]:
"""Return the Slack WebClient for the given team, if available."""
if team_id and team_id in self._team_clients:
return self._team_clients[team_id]
if getattr(self, "_app", None) is not None:
return getattr(self._app, "client", None)
return None

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

async def _probe_slack_file_access_issue(self, file_obj: Dict[str, Any], team_id: str = "") -> Optional[str]:
"""Best-effort diagnosis for Slack attachment access/auth failures before download."""
file_id = str(file_obj.get("id", "") or "").strip()
if not file_id:
return None

client = self._get_team_client(team_id)
if client is None or not hasattr(client, "files_info"):
return None

try:
await client.files_info(file=file_id)
return None
except Exception as exc:
response = getattr(exc, "response", None)
detail = self._describe_slack_api_error(response, file_obj=file_obj)
if detail:
return detail
raise

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:
Expand Down Expand Up @@ -1099,10 +1184,16 @@ async def _handle_slack_message(self, event: dict) -> None:
# Handle file attachments
media_urls = []
media_types = []
attachment_notices: List[str] = []
files = event.get("files", [])
for f in files:
mimetype = f.get("mimetype", "unknown")
url = f.get("url_private_download") or f.get("url_private", "")
access_issue = await self._probe_slack_file_access_issue(f, team_id=team_id)
if access_issue:
attachment_notices.append(access_issue)
logger.warning("[Slack] %s", access_issue)
continue
if mimetype.startswith("image/") and url:
try:
ext = "." + mimetype.split("/")[-1].split(";")[0]
Expand All @@ -1114,7 +1205,12 @@ async def _handle_slack_message(self, event: dict) -> None:
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]
Expand All @@ -1125,7 +1221,12 @@ async def _handle_slack_message(self, event: dict) -> None:
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:
Expand Down Expand Up @@ -1177,7 +1278,16 @@ async def _handle_slack_message(self, event: dict) -> None:
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)
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1061,7 +1061,7 @@ def _ensure_hermes_home_managed(home: Path):
"SLACK_BOT_TOKEN": {
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
"im:history, im:read, im:write, users:read, files:write",
"im:history, im:read, im:write, users:read, files:read, files:write",
"prompt": "Slack Bot Token (xoxb-...)",
"url": "https://api.slack.com/apps",
"password": True,
Expand Down
37 changes: 36 additions & 1 deletion tests/gateway/test_media_download_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ def _ensure_slack_mock():


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()
Expand All @@ -549,6 +549,41 @@ def _make_slack_adapter():
return adapter


# ---------------------------------------------------------------------------
# SlackAdapter diagnostics helpers
# ---------------------------------------------------------------------------

class TestSlackAttachmentDiagnostics:
def test_missing_scope_probe_returns_actionable_notice(self):
adapter = _make_slack_adapter()

class FakeSlackError(Exception):
def __init__(self, response):
super().__init__("missing scope")
self.response = response

adapter._app.client.files_info = AsyncMock(side_effect=FakeSlackError({
"error": "missing_scope",
"needed": "files:read",
"provided": "chat:write,files:write",
}))

async def run():
return await adapter._probe_slack_file_access_issue({"id": "F123", "name": "photo.jpg"})

detail = asyncio.run(run())
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
# ---------------------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions tests/gateway/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,28 @@ async def test_image_still_handled(self, adapter):
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.message_type == MessageType.PHOTO

@pytest.mark.asyncio
async def test_missing_scope_is_surfaced_in_message_text(self, adapter):
"""Attachment permission failures should be surfaced to the agent as a notice block."""
with patch.object(adapter, "_probe_slack_file_access_issue", new_callable=AsyncMock) as probe, \
patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl:
probe.return_value = "Slack attachment access failed for photo.jpg. Missing scope: files:read."
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)

dl.assert_not_called()
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 "files:read" in msg_event.text
assert "what's in this?" in msg_event.text


# ---------------------------------------------------------------------------
# TestMessageRouting
Expand Down
7 changes: 5 additions & 2 deletions website/docs/user-guide/messaging/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ Navigate to **Features → OAuth & Permissions** in the sidebar. Scroll to **Sco
| `im:read` | View basic DM info |
| `im:write` | Open and manage DMs |
| `users:read` | Look up user information |
| `files:read` | Read user-uploaded files/attachments (images, audio, documents) |
| `files:write` | Upload files (images, audio, documents) |

:::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:**
Expand Down Expand Up @@ -428,7 +430,8 @@ Hermes supports voice on Slack:
| "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 |

Expand Down
Loading