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
132 changes: 132 additions & 0 deletions tests/tools/test_send_message_slack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Slack-specific send_message delivery regressions."""

import asyncio
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

from gateway.config import Platform
from tools.send_message_tool import _send_slack, _send_to_platform


def _ensure_slack_mock(monkeypatch):
"""Install lightweight Slack modules when optional Slack deps are absent."""
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return

slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock

slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock

for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
monkeypatch.setitem(sys.modules, name, mod)


def test_slack_send_to_platform_prefers_live_adapter_when_available(monkeypatch):
"""Gateway Slack sends should use the live adapter before standalone HTTP."""
_ensure_slack_mock(monkeypatch)
import gateway.platforms.slack as slack_mod

monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
live_send = AsyncMock(return_value={"success": True, "message_id": "live-ts"})
standalone_send = AsyncMock(side_effect=AssertionError("standalone Slack send should not run"))

with (
patch("tools.send_message_tool._send_via_adapter", live_send),
patch("tools.send_message_tool._send_slack", standalone_send),
):
result = asyncio.run(
_send_to_platform(
Platform.SLACK,
SimpleNamespace(enabled=True, token="bad-token,good-token", extra={}),
"C123",
"**hello** from [Hermes](<https://example.com>)",
thread_id="171.1",
)
)

assert result == {"success": True, "message_id": "live-ts"}
live_send.assert_awaited_once_with(
Platform.SLACK,
SimpleNamespace(enabled=True, token="bad-token,good-token", extra={}),
"C123",
"**hello** from [Hermes](<https://example.com>)",
thread_id="171.1",
media_files=[],
force_document=False,
)
standalone_send.assert_not_awaited()


class _SlackResponse:
def __init__(self, payload):
self._payload = payload

async def json(self):
return self._payload


class _SlackPostContext:
def __init__(self, response):
self._response = response

async def __aenter__(self):
return self._response

async def __aexit__(self, exc_type, exc, tb):
return False


class _SlackSession:
def __init__(self):
self.calls = []

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
return False

def post(self, url, *, headers, json, **kwargs):
token = headers["Authorization"].removeprefix("Bearer ")
self.calls.append((token, json))
if token == "good-token":
payload = {"ok": True, "ts": "171.123"}
else:
payload = {"ok": False, "error": "invalid_auth"}
return _SlackPostContext(_SlackResponse(payload))


def test_send_slack_tries_comma_separated_tokens_individually(monkeypatch):
"""Multi-workspace token lists must not be sent as one literal token."""
fake_session = _SlackSession()

monkeypatch.setattr(
"aiohttp.ClientSession",
lambda *args, **kwargs: fake_session,
)

result = asyncio.run(_send_slack("bad-token, good-token", "C123", "hello"))

assert result == {
"success": True,
"platform": "slack",
"chat_id": "C123",
"message_id": "171.123",
}
assert [token for token, _payload in fake_session.calls] == [
"bad-token",
"good-token",
]
70 changes: 65 additions & 5 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
except ImportError:
_feishu_available = False

original_message = message
if platform == Platform.SLACK and message:
try:
slack_adapter = SlackAdapter.__new__(SlackAdapter)
Expand Down Expand Up @@ -898,6 +899,30 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
"native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu"
)

# Slack's live gateway adapter is multi-workspace aware: it maps inbound
# channels to the workspace/client that delivered the event. The standalone
# token path may only have a comma-separated token list, so prefer the live
# adapter in gateway processes and fall back only when no adapter exists.
if platform == Platform.SLACK:
live_result = await _send_via_adapter(
platform,
pconfig,
chat_id,
original_message,
thread_id=thread_id,
media_files=media_files,
force_document=force_document,
)
if isinstance(live_result, dict) and live_result.get("success"):
if warning:
warnings = list(live_result.get("warnings", []))
warnings.append(warning)
live_result["warnings"] = warnings
return live_result
err = str(live_result.get("error", "")) if isinstance(live_result, dict) else ""
if err and not err.startswith("No live adapter for platform"):
return live_result

last_result = None
for chunk in chunks:
if platform == Platform.SLACK:
Expand Down Expand Up @@ -1182,26 +1207,61 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No


async def _send_slack(token, chat_id, message, thread_ts=None):
"""Send via Slack Web API."""
"""Send via Slack Web API.

``SLACK_BOT_TOKEN`` can be a comma-separated list in multi-workspace
gateways. Try each token independently instead of sending the literal
comma-joined string, which Slack rejects as ``invalid_auth``.
"""
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try:
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp

tokens = [t.strip() for t in str(token or "").split(",") if t.strip()]
try:
from hermes_constants import get_hermes_home

tokens_file = get_hermes_home() / "slack_tokens.json"
if tokens_file.exists():
saved = json.loads(tokens_file.read_text(encoding="utf-8"))
for entry in saved.values():
tok = entry.get("token", "") if isinstance(entry, dict) else ""
if tok and tok not in tokens:
tokens.append(tok)
except Exception:
pass
if not tokens:
return _error("Slack API error: no bot token configured")

_proxy = resolve_proxy_url()
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
url = "https://slack.com/api/chat.postMessage"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
retryable_token_errors = {
"invalid_auth",
"not_authed",
"token_revoked",
"account_inactive",
"not_in_channel",
"channel_not_found",
}
last_error = "unknown"
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
payload = {"channel": chat_id, "text": message, "mrkdwn": True}
if thread_ts:
payload["thread_ts"] = thread_ts
async with session.post(url, headers=headers, json=payload, **_req_kw) as resp:
data = await resp.json()
for tok in tokens:
headers = {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}
async with session.post(url, headers=headers, json=payload, **_req_kw) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}
return _error(f"Slack API error: {data.get('error', 'unknown')}")
last_error = data.get("error", "unknown")
if last_error not in retryable_token_errors:
break
return _error(f"Slack API error: {last_error}")
except Exception as e:
return _error(f"Slack send failed: {e}")

Expand Down