""" Slack Socket Mode adapter for ajarbot. Uses Socket Mode for easy firewall-free integration without webhooks. """ import re from typing import Any, Dict, List, Optional from slack_bolt.adapter.socket_mode.async_handler import ( AsyncSocketModeHandler, ) from slack_bolt.async_app import AsyncApp from slack_sdk.errors import SlackApiError from adapters.base import ( AdapterCapabilities, AdapterConfig, BaseAdapter, InboundMessage, MessageType, OutboundMessage, ) class SlackAdapter(BaseAdapter): """ Slack adapter using Socket Mode. Socket Mode allows receiving events over WebSocket without exposing a public HTTP endpoint - perfect for development and simple deployments. Configuration required: - bot_token: Bot User OAuth Token (xoxb-...) - app_token: App-Level Token (xapp-...) """ def __init__(self, config: AdapterConfig) -> None: super().__init__(config) self.app: Optional[AsyncApp] = None self.handler: Optional[AsyncSocketModeHandler] = None self._username_cache: Dict[str, str] = {} # user_id -> username @property def platform_name(self) -> str: return "slack" @property def capabilities(self) -> AdapterCapabilities: return AdapterCapabilities( supports_threads=True, supports_reactions=True, supports_media=True, supports_files=True, supports_markdown=True, max_message_length=4000, chunking_strategy="word", ) def validate_config(self) -> bool: """Validate Slack configuration. Required scopes for bot token: - files:read (for downloading file attachments) - files:write (for uploading files - future feature) """ if not self.config.credentials: return False bot_token = self.config.credentials.get("bot_token", "") app_token = self.config.credentials.get("app_token", "") valid = ( bool(bot_token and app_token) and bot_token.startswith("xoxb-") and app_token.startswith("xapp-") ) if valid: print("[Slack] ✓ Config valid. Ensure bot has 'files:read' and 'files:write' scopes at api.slack.com") return valid async def start(self) -> None: """Start the Slack Socket Mode connection.""" if not self.validate_config(): raise ValueError( "Invalid Slack configuration. " "Need bot_token (xoxb-...) and app_token (xapp-...)" ) bot_token = self.config.credentials["bot_token"] app_token = self.config.credentials["app_token"] self.app = AsyncApp(token=bot_token) self._register_handlers() self.handler = AsyncSocketModeHandler(self.app, app_token) print("[Slack] Starting Socket Mode connection...") # Connect to Slack (non-blocking) await self.handler.connect_async() self.is_running = True print("[Slack] Connected and listening for messages") async def stop(self) -> None: """Stop the Slack Socket Mode connection.""" if self.handler: print("[Slack] Stopping Socket Mode connection...") await self.handler.disconnect_async() self.is_running = False print("[Slack] Disconnected") def _is_user_allowed(self, user_id: str) -> bool: """Return False if an allow-list is configured and this user is not on it.""" allowed = self.config.settings.get("allowed_users", []) if not allowed: return True # open if unconfigured — preserves existing behaviour return str(user_id) in [str(u) for u in allowed] def _register_handlers(self) -> None: """Register Slack event handlers.""" @self.app.event("message") async def handle_message_events(event, say): """Handle incoming messages.""" if event.get("subtype") in ["bot_message", "message_changed"]: return # Suppress Slack system notifications (channel privacy changes, etc.) raw_text = event.get("text", "") _SUPPRESSED_PATTERNS = [ "made this channel *private*", "has joined the channel", ] if any(p in raw_text for p in _SUPPRESSED_PATTERNS): return user_id = event.get("user") if not self._is_user_allowed(user_id): return text = raw_text channel = event.get("channel") thread_ts = event.get("thread_ts") ts = event.get("ts") files = event.get("files", []) # DEBUG: Log full event structure print(f"[Slack DEBUG] Event subtype: {event.get('subtype')}") print(f"[Slack DEBUG] Event has text: {bool(text)}, text length: {len(text)}") print(f"[Slack DEBUG] Event has files: {bool(files)}, file count: {len(files)}") # DEBUG: Log file detection if files: print(f"[Slack DEBUG] Detected {len(files)} file(s) in message") for f in files: print(f"[Slack DEBUG] File: {f.get('name')} ({f.get('mimetype')}, ID: {f.get('id')})") username = await self._get_username(user_id) # Determine message type message_type = MessageType.FILE if files else MessageType.TEXT # Download files downloaded_files = [] for file_info in files: print(f"[Slack DEBUG] Downloading: {file_info.get('name')} (ID: {file_info.get('id')})") result = await self._download_slack_file(file_info) if result["success"]: print(f"[Slack DEBUG] Downloaded to: {result['file_path']}") downloaded_files.append(result) else: print(f"[Slack] Failed to download file {file_info.get('name')}: {result['error']}") # If files but no text, add placeholder if files and not text: file_names = ", ".join(f["filename"] for f in downloaded_files) text = f"[Uploaded {len(downloaded_files)} file(s): {file_names}]" inbound_msg = InboundMessage( platform="slack", user_id=user_id, username=username, text=text, channel_id=channel, thread_id=thread_ts, reply_to_id=None, message_type=message_type, metadata={ "ts": ts, "team": event.get("team"), "channel_type": event.get("channel_type"), "files": downloaded_files, }, raw=event, ) self._dispatch_message(inbound_msg) @self.app.event("app_mention") async def handle_app_mentions(event, say): """Handle @mentions of the bot.""" user_id = event.get("user") if not self._is_user_allowed(user_id): return text = self._strip_mention(event.get("text", "")) channel = event.get("channel") thread_ts = event.get("thread_ts") ts = event.get("ts") files = event.get("files", []) # DEBUG: Log file detection if files: print(f"[Slack DEBUG @mention] Detected {len(files)} file(s)") for f in files: print(f"[Slack DEBUG @mention] File: {f.get('name')} ({f.get('mimetype')})") username = await self._get_username(user_id) # Determine message type message_type = MessageType.FILE if files else MessageType.TEXT # Download files downloaded_files = [] for file_info in files: print(f"[Slack DEBUG @mention] Downloading: {file_info.get('name')} (ID: {file_info.get('id')})") result = await self._download_slack_file(file_info) if result["success"]: print(f"[Slack DEBUG @mention] Downloaded to: {result['file_path']}") downloaded_files.append(result) else: print(f"[Slack @mention] Failed to download file {file_info.get('name')}: {result['error']}") # If files but no text (after stripping mention), add placeholder if files and not text: file_names = ", ".join(f["filename"] for f in downloaded_files) text = f"[Uploaded {len(downloaded_files)} file(s): {file_names}]" inbound_msg = InboundMessage( platform="slack", user_id=user_id, username=username, text=text, channel_id=channel, thread_id=thread_ts, reply_to_id=None, message_type=message_type, metadata={ "ts": ts, "mentioned": True, "team": event.get("team"), "files": downloaded_files, }, raw=event, ) self._dispatch_message(inbound_msg) async def _download_slack_file( self, file_info: Dict[str, Any], output_dir: str = "downloads/slack" ) -> Dict[str, Any]: """Download a file from Slack using url_private_download. Args: file_info: File object from Slack event (contains url_private_download, name, etc.) output_dir: Directory to save files (default: "downloads/slack") Returns: Dict with success, file_path, filename, mimetype, size, or error """ import aiohttp from pathlib import Path from datetime import datetime url = file_info.get("url_private_download") token = self.config.credentials["bot_token"] headers = {"Authorization": f"Bearer {token}"} try: async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as response: if response.status == 403: return { "success": False, "error": "Permission denied. Add 'files:read' scope to bot at api.slack.com → OAuth & Permissions → Bot Token Scopes" } elif response.status == 404: return {"success": False, "error": "File not found or expired"} elif response.status != 200: return {"success": False, "error": f"HTTP {response.status}"} content_type = response.headers.get("Content-Type", "") file_data = await response.read() # Detect HTML login page (auth failure) if content_type.startswith("text/html") or file_data.startswith(b" Dict[str, Any]: """Send a message to Slack.""" if not self.app: return {"success": False, "error": "Adapter not started"} try: chunks = self.chunk_text(message.text) results: List[Dict[str, Any]] = [] for i, chunk in enumerate(chunks): thread_ts = ( message.thread_id if i == 0 else results[0].get("ts") ) result = await self.app.client.chat_postMessage( channel=message.channel_id, text=chunk, thread_ts=thread_ts, mrkdwn=True, ) results.append({ "ts": result["ts"], "channel": result["channel"], }) return { "success": True, "message_id": results[0]["ts"], "chunks_sent": len(chunks), "results": results, } except SlackApiError as e: error_msg = e.response["error"] print(f"[Slack] Error sending message: {error_msg}") return {"success": False, "error": error_msg} async def send_reaction( self, channel_id: str, message_id: str, emoji: str ) -> bool: """Add a reaction to a message.""" if not self.app: return False try: await self.app.client.reactions_add( channel=channel_id, timestamp=message_id, name=emoji.strip(":"), ) return True except SlackApiError as e: print( f"[Slack] Error adding reaction: {e.response['error']}" ) return False async def send_typing_indicator(self, channel_id: str) -> None: """Slack doesn't have a typing indicator API.""" async def health_check(self) -> Dict[str, Any]: """Perform health check.""" base_health = await super().health_check() if not self.app: return {**base_health, "details": "App not initialized"} try: response = await self.app.client.auth_test() return { **base_health, "bot_id": response.get("bot_id"), "team": response.get("team"), "user": response.get("user"), "connected": True, } except SlackApiError as e: return { **base_health, "healthy": False, "error": str(e.response.get("error")), } async def send_file( self, channel_id: str, file_path: str, caption: Optional[str] = None, thread_id: Optional[str] = None ) -> Dict[str, Any]: """Upload a file to Slack channel.""" if not self.app: return {"success": False, "error": "Adapter not started"} try: from pathlib import Path path = Path(file_path) if not path.exists(): return {"success": False, "error": f"File not found: {file_path}"} result = await self.app.client.files_upload_v2( channel=channel_id, file=str(path.absolute()), title=path.name, initial_comment=caption or "", thread_ts=thread_id, ) return { "success": True, "message_id": result["file"]["id"], "file_path": file_path, } except SlackApiError as e: error_msg = e.response["error"] print(f"[Slack] Error uploading file: {error_msg}") return {"success": False, "error": error_msg} except Exception as e: print(f"[Slack] Unexpected error uploading file: {e}") return {"success": False, "error": str(e)} async def _get_username(self, user_id: str) -> str: """Get username from user ID, with caching to avoid excessive API calls. Sanitizes the returned username to contain only alphanumeric, hyphens, and underscores (matching memory_system validation rules). """ # Check cache first if user_id in self._username_cache: return self._username_cache[user_id] if not self.app: return user_id try: result = await self.app.client.users_info(user=user_id) user = result["user"] profile = user.get("profile", {}) raw_username = ( profile.get("display_name") or profile.get("real_name") or user.get("name") or user_id ) # Sanitize: replace spaces/special chars with underscores sanitized = "".join( c if c.isalnum() or c in "-_" else "_" for c in raw_username ) self._username_cache[user_id] = sanitized return sanitized except SlackApiError: self._username_cache[user_id] = user_id return user_id @staticmethod def _strip_mention(text: str) -> str: """Remove bot mention from text (e.g., '<@U12345> hello' -> 'hello').""" return re.sub(r"<@[A-Z0-9]+>", "", text).strip()