feat(slack): multi-workspace support via OAuth token file (#3903)

Salvaged from PR #2033 by yoannes. Adds multi-workspace Slack support
so a single Hermes instance can serve multiple Slack workspaces after
OAuth installs.

Changes:
- Support comma-separated bot tokens in SLACK_BOT_TOKEN env var
- Load additional OAuth-persisted tokens from HERMES_HOME/slack_tokens.json
- Route all Slack API calls through workspace-aware _get_client(chat_id)
  instead of always using the primary app client
- Track channel → workspace mapping from incoming events
- Per-workspace bot_user_id for correct mention detection
- Workspace-aware file downloads (correct auth token per workspace)

Backward compatible: single-token setups work identically.

Token file format (slack_tokens.json):
  {"T12345": {"token": "xoxb-...", "team_name": "My Workspace"}}

Fixed from original PR:
- Uses get_hermes_home() instead of hardcoded ~/.hermes/ path

Co-authored-by: yoannes <yoannes@users.noreply.github.com>
This commit is contained in:
Teknium 2026-03-30 01:51:48 -07:00 committed by GitHub
parent a4b064763d
commit 791f4e94b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -9,6 +9,7 @@ Uses slack-bolt (Python) with Socket Mode for:
""" """
import asyncio import asyncio
import json
import logging import logging
import os import os
import re import re
@ -73,6 +74,10 @@ class SlackAdapter(BasePlatformAdapter):
self._bot_user_id: Optional[str] = None self._bot_user_id: Optional[str] = None
self._user_name_cache: Dict[str, str] = {} # user_id → display name self._user_name_cache: Dict[str, str] = {} # user_id → display name
self._socket_mode_task: Optional[asyncio.Task] = None self._socket_mode_task: Optional[asyncio.Task] = None
# Multi-workspace support
self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient
self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id
self._channel_team: Dict[str, str] = {} # channel_id → team_id
async def connect(self) -> bool: async def connect(self) -> bool:
"""Connect to Slack via Socket Mode.""" """Connect to Slack via Socket Mode."""
@ -82,16 +87,34 @@ class SlackAdapter(BasePlatformAdapter):
) )
return False return False
bot_token = self.config.token raw_token = self.config.token
app_token = os.getenv("SLACK_APP_TOKEN") app_token = os.getenv("SLACK_APP_TOKEN")
if not bot_token: if not raw_token:
logger.error("[Slack] SLACK_BOT_TOKEN not set") logger.error("[Slack] SLACK_BOT_TOKEN not set")
return False return False
if not app_token: if not app_token:
logger.error("[Slack] SLACK_APP_TOKEN not set") logger.error("[Slack] SLACK_APP_TOKEN not set")
return False return False
# Support comma-separated bot tokens for multi-workspace
bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()]
# Also load tokens from OAuth token file
from hermes_constants import get_hermes_home
tokens_file = get_hermes_home() / "slack_tokens.json"
if tokens_file.exists():
try:
saved = json.loads(tokens_file.read_text(encoding="utf-8"))
for team_id, entry in saved.items():
tok = entry.get("token", "") if isinstance(entry, dict) else ""
if tok and tok not in bot_tokens:
bot_tokens.append(tok)
team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id
logger.info("[Slack] Loaded saved token for workspace %s", team_label)
except Exception as e:
logger.warning("[Slack] Failed to read %s: %s", tokens_file, e)
try: try:
# Acquire scoped lock to prevent duplicate app token usage # Acquire scoped lock to prevent duplicate app token usage
from gateway.status import acquire_scoped_lock from gateway.status import acquire_scoped_lock
@ -104,12 +127,30 @@ class SlackAdapter(BasePlatformAdapter):
self._set_fatal_error('slack_token_lock', message, retryable=False) self._set_fatal_error('slack_token_lock', message, retryable=False)
return False return False
self._app = AsyncApp(token=bot_token) # First token is the primary — used for AsyncApp / Socket Mode
primary_token = bot_tokens[0]
self._app = AsyncApp(token=primary_token)
# Get our own bot user ID for mention detection # Register each bot token and map team_id → client
auth_response = await self._app.client.auth_test() for token in bot_tokens:
self._bot_user_id = auth_response.get("user_id") client = AsyncWebClient(token=token)
auth_response = await client.auth_test()
team_id = auth_response.get("team_id", "")
bot_user_id = auth_response.get("user_id", "")
bot_name = auth_response.get("user", "unknown") bot_name = auth_response.get("user", "unknown")
team_name = auth_response.get("team", "unknown")
self._team_clients[team_id] = client
self._team_bot_user_ids[team_id] = bot_user_id
# First token sets the primary bot_user_id (backward compat)
if self._bot_user_id is None:
self._bot_user_id = bot_user_id
logger.info(
"[Slack] Authenticated as @%s in workspace %s (team: %s)",
bot_name, team_name, team_id,
)
# Register message event handler # Register message event handler
@self._app.event("message") @self._app.event("message")
@ -134,7 +175,10 @@ class SlackAdapter(BasePlatformAdapter):
self._socket_mode_task = asyncio.create_task(self._handler.start_async()) self._socket_mode_task = asyncio.create_task(self._handler.start_async())
self._running = True self._running = True
logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name) logger.info(
"[Slack] Socket Mode connected (%d workspace(s))",
len(self._team_clients),
)
return True return True
except Exception as e: # pragma: no cover - defensive logging except Exception as e: # pragma: no cover - defensive logging
@ -161,6 +205,13 @@ class SlackAdapter(BasePlatformAdapter):
logger.info("[Slack] Disconnected") logger.info("[Slack] Disconnected")
def _get_client(self, chat_id: str) -> AsyncWebClient:
"""Return the workspace-specific WebClient for a channel."""
team_id = self._channel_team.get(chat_id)
if team_id and team_id in self._team_clients:
return self._team_clients[team_id]
return self._app.client # fallback to primary
async def send( async def send(
self, self,
chat_id: str, chat_id: str,
@ -197,7 +248,7 @@ class SlackAdapter(BasePlatformAdapter):
if broadcast and i == 0: if broadcast and i == 0:
kwargs["reply_broadcast"] = True kwargs["reply_broadcast"] = True
last_result = await self._app.client.chat_postMessage(**kwargs) last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
return SendResult( return SendResult(
success=True, success=True,
@ -219,7 +270,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app: if not self._app:
return SendResult(success=False, error="Not connected") return SendResult(success=False, error="Not connected")
try: try:
await self._app.client.chat_update( await self._get_client(chat_id).chat_update(
channel=chat_id, channel=chat_id,
ts=message_id, ts=message_id,
text=content, text=content,
@ -253,7 +304,7 @@ class SlackAdapter(BasePlatformAdapter):
return # Can only set status in a thread context return # Can only set status in a thread context
try: try:
await self._app.client.assistant_threads_setStatus( await self._get_client(chat_id).assistant_threads_setStatus(
channel_id=chat_id, channel_id=chat_id,
thread_ts=thread_ts, thread_ts=thread_ts,
status="is thinking...", status="is thinking...",
@ -295,7 +346,7 @@ class SlackAdapter(BasePlatformAdapter):
if not os.path.exists(file_path): if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}") raise FileNotFoundError(f"File not found: {file_path}")
result = await self._app.client.files_upload_v2( result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id, channel=chat_id,
file=file_path, file=file_path,
filename=os.path.basename(file_path), filename=os.path.basename(file_path),
@ -397,7 +448,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app: if not self._app:
return False return False
try: try:
await self._app.client.reactions_add( await self._get_client(channel).reactions_add(
channel=channel, timestamp=timestamp, name=emoji channel=channel, timestamp=timestamp, name=emoji
) )
return True return True
@ -413,7 +464,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app: if not self._app:
return False return False
try: try:
await self._app.client.reactions_remove( await self._get_client(channel).reactions_remove(
channel=channel, timestamp=timestamp, name=emoji channel=channel, timestamp=timestamp, name=emoji
) )
return True return True
@ -423,7 +474,7 @@ class SlackAdapter(BasePlatformAdapter):
# ----- User identity resolution ----- # ----- User identity resolution -----
async def _resolve_user_name(self, user_id: str) -> str: async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
"""Resolve a Slack user ID to a display name, with caching.""" """Resolve a Slack user ID to a display name, with caching."""
if not user_id: if not user_id:
return "" return ""
@ -434,7 +485,8 @@ class SlackAdapter(BasePlatformAdapter):
return user_id return user_id
try: try:
result = await self._app.client.users_info(user=user_id) client = self._get_client(chat_id) if chat_id else self._app.client
result = await client.users_info(user=user_id)
user = result.get("user", {}) user = result.get("user", {})
# Prefer display_name → real_name → user_id # Prefer display_name → real_name → user_id
profile = user.get("profile", {}) profile = user.get("profile", {})
@ -498,7 +550,7 @@ class SlackAdapter(BasePlatformAdapter):
response = await client.get(image_url) response = await client.get(image_url)
response.raise_for_status() response.raise_for_status()
result = await self._app.client.files_upload_v2( result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id, channel=chat_id,
content=response.content, content=response.content,
filename="image.png", filename="image.png",
@ -558,7 +610,7 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error=f"Video file not found: {video_path}") return SendResult(success=False, error=f"Video file not found: {video_path}")
try: try:
result = await self._app.client.files_upload_v2( result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id, channel=chat_id,
file=video_path, file=video_path,
filename=os.path.basename(video_path), filename=os.path.basename(video_path),
@ -599,7 +651,7 @@ class SlackAdapter(BasePlatformAdapter):
display_name = file_name or os.path.basename(file_path) display_name = file_name or os.path.basename(file_path)
try: try:
result = await self._app.client.files_upload_v2( result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id, channel=chat_id,
file=file_path, file=file_path,
filename=display_name, filename=display_name,
@ -627,7 +679,7 @@ class SlackAdapter(BasePlatformAdapter):
return {"name": chat_id, "type": "unknown"} return {"name": chat_id, "type": "unknown"}
try: try:
result = await self._app.client.conversations_info(channel=chat_id) result = await self._get_client(chat_id).conversations_info(channel=chat_id)
channel = result.get("channel", {}) channel = result.get("channel", {})
is_dm = channel.get("is_im", False) is_dm = channel.get("is_im", False)
return { return {
@ -660,6 +712,11 @@ class SlackAdapter(BasePlatformAdapter):
user_id = event.get("user", "") user_id = event.get("user", "")
channel_id = event.get("channel", "") channel_id = event.get("channel", "")
ts = event.get("ts", "") ts = event.get("ts", "")
team_id = event.get("team", "")
# Track which workspace owns this channel
if team_id and channel_id:
self._channel_team[channel_id] = team_id
# Determine if this is a DM or channel message # Determine if this is a DM or channel message
channel_type = event.get("channel_type", "") channel_type = event.get("channel_type", "")
@ -676,11 +733,12 @@ class SlackAdapter(BasePlatformAdapter):
thread_ts = event.get("thread_ts") or ts # ts fallback for channels thread_ts = event.get("thread_ts") or ts # ts fallback for channels
# In channels, only respond if bot is mentioned # In channels, only respond if bot is mentioned
if not is_dm and self._bot_user_id: bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
if f"<@{self._bot_user_id}>" not in text: if not is_dm and bot_uid:
if f"<@{bot_uid}>" not in text:
return return
# Strip the bot mention from the text # Strip the bot mention from the text
text = text.replace(f"<@{self._bot_user_id}>", "").strip() text = text.replace(f"<@{bot_uid}>", "").strip()
# Determine message type # Determine message type
msg_type = MessageType.TEXT msg_type = MessageType.TEXT
@ -700,7 +758,7 @@ class SlackAdapter(BasePlatformAdapter):
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
ext = ".jpg" ext = ".jpg"
# Slack private URLs require the bot token as auth header # Slack private URLs require the bot token as auth header
cached = await self._download_slack_file(url, ext) cached = await self._download_slack_file(url, ext, team_id=team_id)
media_urls.append(cached) media_urls.append(cached)
media_types.append(mimetype) media_types.append(mimetype)
msg_type = MessageType.PHOTO msg_type = MessageType.PHOTO
@ -711,7 +769,7 @@ class SlackAdapter(BasePlatformAdapter):
ext = "." + mimetype.split("/")[-1].split(";")[0] ext = "." + mimetype.split("/")[-1].split(";")[0]
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
ext = ".ogg" ext = ".ogg"
cached = await self._download_slack_file(url, ext, audio=True) cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id)
media_urls.append(cached) media_urls.append(cached)
media_types.append(mimetype) media_types.append(mimetype)
msg_type = MessageType.VOICE msg_type = MessageType.VOICE
@ -742,7 +800,7 @@ class SlackAdapter(BasePlatformAdapter):
continue continue
# Download and cache # Download and cache
raw_bytes = await self._download_slack_file_bytes(url) raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id)
cached_path = cache_document_from_bytes( cached_path = cache_document_from_bytes(
raw_bytes, original_filename or f"document{ext}" raw_bytes, original_filename or f"document{ext}"
) )
@ -771,7 +829,7 @@ class SlackAdapter(BasePlatformAdapter):
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True) logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
# Resolve user display name (cached after first lookup) # Resolve user display name (cached after first lookup)
user_name = await self._resolve_user_name(user_id) user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
# Build source # Build source
source = self.build_source( source = self.build_source(
@ -808,6 +866,11 @@ class SlackAdapter(BasePlatformAdapter):
text = command.get("text", "").strip() text = command.get("text", "").strip()
user_id = command.get("user_id", "") user_id = command.get("user_id", "")
channel_id = command.get("channel_id", "") channel_id = command.get("channel_id", "")
team_id = command.get("team_id", "")
# Track which workspace owns this channel
if team_id and channel_id:
self._channel_team[channel_id] = team_id
# Map subcommands to gateway commands — derived from central registry. # Map subcommands to gateway commands — derived from central registry.
# Also keep "compact" as a Slack-specific alias for /compress. # Also keep "compact" as a Slack-specific alias for /compress.
@ -839,12 +902,12 @@ class SlackAdapter(BasePlatformAdapter):
await self.handle_message(event) await self.handle_message(event)
async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str: async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
"""Download a Slack file using the bot token for auth, with retry.""" """Download a Slack file using the bot token for auth, with retry."""
import asyncio import asyncio
import httpx import httpx
bot_token = self.config.token bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
last_exc = None last_exc = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
@ -874,12 +937,12 @@ class SlackAdapter(BasePlatformAdapter):
raise raise
raise last_exc raise last_exc
async def _download_slack_file_bytes(self, url: str) -> bytes: async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes:
"""Download a Slack file and return raw bytes, with retry.""" """Download a Slack file and return raw bytes, with retry."""
import asyncio import asyncio
import httpx import httpx
bot_token = self.config.token bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
last_exc = None last_exc = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: