""" Aetheel Slack Adapter ===================== Connects to Slack via Socket Mode (no public URL needed). Inspired by OpenClaw's Slack implementation (src/slack/). Features: - Socket Mode connection (no public URL / webhook needed) - Receives DMs and @mentions in channels - Each thread = persistent conversation context - Sends replies back to the same thread - Extends BaseAdapter for multi-channel support Usage: from adapters.slack_adapter import SlackAdapter adapter = SlackAdapter() adapter.on_message(my_handler) adapter.start() """ import logging import os import re from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Callable from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from slack_sdk import WebClient from slack_sdk.errors import SlackApiError from adapters.base import BaseAdapter, IncomingMessage logger = logging.getLogger("aetheel.slack") # --------------------------------------------------------------------------- # Types (Slack-specific, kept for internal use) # --------------------------------------------------------------------------- @dataclass class SlackSendResult: """Result of sending a Slack message.""" message_id: str channel_id: str thread_ts: str | None = None # --------------------------------------------------------------------------- # Token Resolution (inspired by OpenClaw src/slack/token.ts) # --------------------------------------------------------------------------- def resolve_bot_token(explicit: str | None = None) -> str: """Resolve the Slack bot token.""" token = (explicit or os.environ.get("SLACK_BOT_TOKEN", "")).strip() if not token: raise ValueError( "Slack bot token is required. " "Set SLACK_BOT_TOKEN environment variable or pass it explicitly." ) if not token.startswith("xoxb-"): logger.warning("Bot token doesn't start with 'xoxb-' — double-check the token.") return token def resolve_app_token(explicit: str | None = None) -> str: """Resolve the Slack app-level token (required for Socket Mode).""" token = (explicit or os.environ.get("SLACK_APP_TOKEN", "")).strip() if not token: raise ValueError( "Slack app-level token is required for Socket Mode. " "Set SLACK_APP_TOKEN environment variable or pass it explicitly." ) if not token.startswith("xapp-"): logger.warning("App token doesn't start with 'xapp-' — double-check the token.") return token # --------------------------------------------------------------------------- # Slack Adapter # --------------------------------------------------------------------------- class SlackAdapter(BaseAdapter): """ Slack adapter using Socket Mode, extending BaseAdapter. Inspired by OpenClaw's monitorSlackProvider() in src/slack/monitor/provider.ts. Converts Slack events into IncomingMessage objects before dispatching. """ def __init__( self, bot_token: str | None = None, app_token: str | None = None, log_level: str = "INFO", ): super().__init__() self._bot_token = resolve_bot_token(bot_token) self._app_token = resolve_app_token(app_token) self._bot_user_id: str = "" self._bot_user_name: str = "" self._team_id: str = "" self._user_cache: dict[str, str] = {} self._channel_cache: dict[str, str] = {} self._running = False self._socket_handler: SocketModeHandler | None = None # Initialize Slack Bolt app with Socket Mode self._app = App( token=self._bot_token, logger=logger, ) self._client: WebClient = self._app.client # Register event handlers self._register_handlers() # ------------------------------------------------------------------- # BaseAdapter implementation # ------------------------------------------------------------------- @property def source_name(self) -> str: return "slack" def start(self) -> None: """Start the Slack adapter in Socket Mode (blocking).""" self._resolve_identity() logger.info("=" * 60) logger.info(" Aetheel Slack Adapter") logger.info("=" * 60) logger.info(f" Bot: @{self._bot_user_name} ({self._bot_user_id})") logger.info(f" Team: {self._team_id}") logger.info(f" Mode: Socket Mode") logger.info(f" Handlers: {len(self._message_handlers)} registered") logger.info("=" * 60) self._running = True self._socket_handler = SocketModeHandler(self._app, self._app_token) try: self._socket_handler.start() except KeyboardInterrupt: logger.info("Shutting down...") self.stop() def start_async(self) -> None: """Start the adapter in a background thread (non-blocking).""" self._resolve_identity() self._running = True self._socket_handler = SocketModeHandler(self._app, self._app_token) self._socket_handler.connect() logger.info( f"Slack adapter connected (bot=@{self._bot_user_name}, mode=socket)" ) def stop(self) -> None: """Stop the Slack adapter gracefully.""" self._running = False if self._socket_handler: try: self._socket_handler.close() except Exception: pass logger.info("Slack adapter stopped.") def send_message( self, channel_id: str, text: str, thread_id: str | None = None, ) -> None: """ Send a message to a Slack channel or DM. Mirrors OpenClaw's sendMessageSlack() in src/slack/send.ts. """ if not text.strip(): return # If it looks like a user ID, open a DM first if channel_id.startswith("U") or channel_id.startswith("W"): try: dm_response = self._client.conversations_open(users=[channel_id]) channel_id = dm_response["channel"]["id"] except SlackApiError as e: raise RuntimeError(f"Failed to open DM with {channel_id}: {e}") from e # Chunk long messages (Slack's limit = 4000 chars) SLACK_TEXT_LIMIT = 4000 chunks = self._chunk_text(text, SLACK_TEXT_LIMIT) for chunk in chunks: try: self._client.chat_postMessage( channel=channel_id, text=chunk, thread_ts=thread_id, ) except SlackApiError as e: logger.error(f"Failed to send message: {e}") raise # ------------------------------------------------------------------- # Internal: Event handlers # ------------------------------------------------------------------- def _register_handlers(self) -> None: """Register Slack event handlers on the Bolt app.""" @self._app.event("message") def handle_message_event(event: dict, say: Callable, client: WebClient) -> None: raw_text = event.get("text", "") if self._bot_user_id and f"<@{self._bot_user_id}>" in raw_text: return self._process_incoming(event, say, client) @self._app.event("app_mention") def handle_mention_event(event: dict, say: Callable, client: WebClient) -> None: self._process_incoming(event, say, client, is_mention=True) def _process_incoming( self, event: dict, say: Callable, client: WebClient, is_mention: bool = False, ) -> None: """ Process an incoming Slack message. Converts to IncomingMessage and dispatches to handlers. """ # Skip bot messages if event.get("bot_id") or event.get("subtype") in ( "bot_message", "message_changed", "message_deleted", "channel_join", "channel_leave", ): return user_id = event.get("user", "") if not user_id or user_id == self._bot_user_id: return raw_text = event.get("text", "") channel_id = event.get("channel", "") channel_type = event.get("channel_type", "") thread_ts = event.get("thread_ts") message_ts = event.get("ts", "") is_dm = channel_type in ("im", "mpim") # Strip the bot mention from the text clean_text = self._strip_mention(raw_text).strip() if not clean_text: return # Resolve user and channel names user_name = self._resolve_user_name(user_id, client) channel_name = self._resolve_channel_name(channel_id, client) # Conversation ID for session isolation (thread-based) conversation_id = thread_ts or message_ts # Build channel-agnostic IncomingMessage msg = IncomingMessage( text=clean_text, user_id=user_id, user_name=user_name, channel_id=channel_id, channel_name=channel_name, conversation_id=conversation_id, source="slack", is_dm=is_dm, timestamp=datetime.fromtimestamp(float(message_ts), tz=timezone.utc) if message_ts else datetime.now(timezone.utc), raw_event={ "thread_id": thread_ts or message_ts, # for BaseAdapter._dispatch "thread_ts": thread_ts, "message_ts": message_ts, "is_mention": is_mention, "is_thread_reply": thread_ts is not None, "channel_type": channel_type, }, ) logger.info( f"📨 Message from @{user_name} in #{channel_name}: {clean_text[:100]}" ) # Dispatch to handlers via BaseAdapter self._dispatch(msg) # ------------------------------------------------------------------- # Internal: Helpers # ------------------------------------------------------------------- def _resolve_identity(self) -> None: """Resolve bot identity via auth.test.""" try: auth = self._client.auth_test() self._bot_user_id = auth.get("user_id", "") self._bot_user_name = auth.get("user", "unknown") self._team_id = auth.get("team_id", "") logger.info( f"Auth OK: bot={self._bot_user_name} team={self._team_id}" ) except SlackApiError as e: logger.warning(f"auth.test failed (non-fatal): {e}") def _strip_mention(self, text: str) -> str: """Remove @bot mentions from message text.""" if self._bot_user_id: text = re.sub(rf"<@{re.escape(self._bot_user_id)}>", "", text) return text.strip() def _resolve_user_name(self, user_id: str, client: WebClient) -> str: """Resolve a user ID to a display name (with caching).""" if user_id in self._user_cache: return self._user_cache[user_id] try: info = client.users_info(user=user_id) name = ( info["user"].get("real_name") or info["user"].get("name") or user_id ) self._user_cache[user_id] = name return name except SlackApiError: self._user_cache[user_id] = user_id return user_id def _resolve_channel_name(self, channel_id: str, client: WebClient) -> str: """Resolve a channel ID to a name (with caching).""" if channel_id in self._channel_cache: return self._channel_cache[channel_id] try: info = client.conversations_info(channel=channel_id) name = info["channel"].get("name", channel_id) self._channel_cache[channel_id] = name return name except SlackApiError: self._channel_cache[channel_id] = channel_id return channel_id @staticmethod def _chunk_text(text: str, limit: int = 4000) -> list[str]: """ Split text into chunks respecting Slack's character limit. Tries to split at newlines, then at spaces, then hard-splits. """ if len(text) <= limit: return [text] chunks = [] remaining = text while remaining: if len(remaining) <= limit: chunks.append(remaining) break cut = limit newline_pos = remaining.rfind("\n", 0, limit) if newline_pos > limit // 2: cut = newline_pos + 1 else: space_pos = remaining.rfind(" ", 0, limit) if space_pos > limit // 2: cut = space_pos + 1 chunks.append(remaining[:cut]) remaining = remaining[cut:] return chunks