""" 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 - Message handler callback for plugging in AI logic Architecture (from OpenClaw): - OpenClaw uses @slack/bolt (Node.js) with socketMode: true - We replicate this with slack_bolt (Python) which is the official Python SDK - Like OpenClaw, we separate: token resolution, message handling, and sending Usage: from adapters.slack_adapter import SlackAdapter adapter = SlackAdapter() adapter.on_message(my_handler) adapter.start() """ import logging import os import re import threading import time 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 logger = logging.getLogger("aetheel.slack") # --------------------------------------------------------------------------- # Types # --------------------------------------------------------------------------- @dataclass class SlackMessage: """Represents an incoming Slack message (mirrors OpenClaw's SlackMessageEvent).""" text: str user_id: str user_name: str channel_id: str channel_name: str thread_ts: str | None message_ts: str is_dm: bool is_mention: bool is_thread_reply: bool raw_event: dict = field(default_factory=dict, repr=False) @property def conversation_id(self) -> str: """ Unique conversation identifier. Uses thread_ts if in a thread, otherwise the message_ts. This mirrors OpenClaw's session isolation per thread. """ return self.thread_ts or self.message_ts @property def timestamp(self) -> datetime: """Parse Slack ts into a datetime.""" ts_float = float(self.message_ts) return datetime.fromtimestamp(ts_float, tz=timezone.utc) @dataclass class SlackSendResult: """Result of sending a Slack message (mirrors OpenClaw's SlackSendResult).""" message_id: str channel_id: str thread_ts: str | None = None # Type alias for the message handler callback MessageHandler = Callable[[SlackMessage], str | None] # --------------------------------------------------------------------------- # Token Resolution (inspired by OpenClaw src/slack/token.ts) # --------------------------------------------------------------------------- def resolve_bot_token(explicit: str | None = None) -> str: """ Resolve the Slack bot token. Priority: explicit param > SLACK_BOT_TOKEN env var. """ 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). Priority: explicit param > SLACK_APP_TOKEN env var. """ 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: """ Slack adapter using Socket Mode. Inspired by OpenClaw's monitorSlackProvider() in src/slack/monitor/provider.ts: - Connects via Socket Mode (no public URL needed) - Handles DMs and @mentions - Thread-based conversation isolation - Configurable message handler callback Example: adapter = SlackAdapter() @adapter.on_message def handle(msg: SlackMessage) -> str: return f"Echo: {msg.text}" adapter.start() """ def __init__( self, bot_token: str | None = None, app_token: str | None = None, log_level: str = "INFO", ): self._bot_token = resolve_bot_token(bot_token) self._app_token = resolve_app_token(app_token) self._message_handlers: list[MessageHandler] = [] 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 # Configure logging logging.basicConfig( level=getattr(logging, log_level.upper(), logging.INFO), format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) # Initialize Slack Bolt app with Socket Mode # This mirrors OpenClaw's: new App({ token: botToken, appToken, socketMode: true }) self._app = App( token=self._bot_token, logger=logger, ) self._client: WebClient = self._app.client # Register event handlers self._register_handlers() # ------------------------------------------------------------------- # Public API # ------------------------------------------------------------------- def on_message(self, handler: MessageHandler) -> MessageHandler: """ Register a message handler (can be used as a decorator). The handler receives a SlackMessage and should return a response string or None. """ self._message_handlers.append(handler) return handler def start(self) -> None: """ Start the Slack adapter in Socket Mode. This is a blocking call (like OpenClaw's `await app.start()`). """ # Resolve bot identity (like OpenClaw's auth.test call) 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: str, text: str, thread_ts: str | None = None, ) -> SlackSendResult: """ Send a message to a Slack channel or DM. Mirrors OpenClaw's sendMessageSlack() in src/slack/send.ts. Args: channel: Channel ID (C...), user ID (U...), or DM channel (D...) text: Message text (supports Slack mrkdwn formatting) thread_ts: Optional thread timestamp to reply in a thread Returns: SlackSendResult with message_id and channel_id """ if not text.strip(): raise ValueError("Cannot send an empty message.") # If it looks like a user ID, open a DM first # (mirrors OpenClaw's resolveChannelId) if channel.startswith("U") or channel.startswith("W"): try: dm_response = self._client.conversations_open(users=[channel]) channel = dm_response["channel"]["id"] except SlackApiError as e: raise RuntimeError(f"Failed to open DM with {channel}: {e}") from e # Chunk long messages (OpenClaw's SLACK_TEXT_LIMIT = 4000) SLACK_TEXT_LIMIT = 4000 chunks = self._chunk_text(text, SLACK_TEXT_LIMIT) last_ts = "" for chunk in chunks: try: response = self._client.chat_postMessage( channel=channel, text=chunk, thread_ts=thread_ts, ) last_ts = response.get("ts", "") except SlackApiError as e: logger.error(f"Failed to send message: {e}") raise return SlackSendResult( message_id=last_ts or "unknown", channel_id=channel, thread_ts=thread_ts, ) # ------------------------------------------------------------------- # Internal: Event handlers # ------------------------------------------------------------------- def _register_handlers(self) -> None: """Register Slack event handlers on the Bolt app.""" # Handle direct messages and channel messages WITHOUT @mention # Note: When someone @mentions the bot, Slack fires BOTH a "message" # event and an "app_mention" event. We only want to respond once, # so the message handler skips messages containing the bot @mention. # Those are handled exclusively by handle_mention_event below. @self._app.event("message") def handle_message_event(event: dict, say: Callable, client: WebClient) -> None: # Skip if this message contains an @mention of our bot # (it will be handled by app_mention instead) 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) # Handle @mentions in channels @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. This is the core handler, inspired by OpenClaw's createSlackMessageHandler(). """ # Skip bot messages (including our own) 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 (like OpenClaw does) 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) # Build the SlackMessage object msg = SlackMessage( text=clean_text, user_id=user_id, user_name=user_name, channel_id=channel_id, channel_name=channel_name, thread_ts=thread_ts, message_ts=message_ts, is_dm=is_dm, is_mention=is_mention, is_thread_reply=thread_ts is not None, raw_event=event, ) logger.info( f"📨 Message from @{user_name} in #{channel_name}: {clean_text[:100]}" ) # Call all registered handlers for handler in self._message_handlers: try: response = handler(msg) if response: # Reply in the same thread # (OpenClaw uses thread_ts for thread isolation) reply_thread = thread_ts or message_ts say(text=response, thread_ts=reply_thread) logger.info( f"📤 Reply sent to #{channel_name} (thread={reply_thread[:10]}...)" ) except Exception as e: logger.error(f"Handler error: {e}", exc_info=True) try: say( text=f"⚠️ Something went wrong processing your message.", thread_ts=thread_ts or message_ts, ) except Exception: pass # ------------------------------------------------------------------- # Internal: Helpers # ------------------------------------------------------------------- def _resolve_identity(self) -> None: """ Resolve bot identity via auth.test. Mirrors OpenClaw's auth.test call in monitorSlackProvider(). """ 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. Mirrors OpenClaw's chunkMarkdownTextWithMode(). 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 # Try to find a good break point cut = limit # Prefer breaking at a newline newline_pos = remaining.rfind("\n", 0, limit) if newline_pos > limit // 2: cut = newline_pos + 1 else: # Fall back to space space_pos = remaining.rfind(" ", 0, limit) if space_pos > limit // 2: cut = space_pos + 1 chunks.append(remaining[:cut]) remaining = remaining[cut:] return chunks