"""Slack channel implementation using Socket Mode.""" import asyncio import re from typing import Any from loguru import logger from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.web.async_client import AsyncWebClient from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import SlackConfig class SlackChannel(BaseChannel): """Slack channel using Socket Mode.""" name = "slack" def __init__(self, config: SlackConfig, bus: MessageBus): super().__init__(config, bus) self.config: SlackConfig = config self._web_client: AsyncWebClient | None = None self._socket_client: SocketModeClient | None = None self._bot_user_id: str | None = None async def start(self) -> None: """Start the Slack Socket Mode client.""" if not self.config.bot_token or not self.config.app_token: logger.error("Slack bot/app token not configured") return if self.config.mode != "socket": logger.error(f"Unsupported Slack mode: {self.config.mode}") return self._running = True self._web_client = AsyncWebClient(token=self.config.bot_token) self._socket_client = SocketModeClient( app_token=self.config.app_token, web_client=self._web_client, ) self._socket_client.socket_mode_request_listeners.append(self._on_socket_request) # Resolve bot user ID for mention handling try: auth = await self._web_client.auth_test() self._bot_user_id = auth.get("user_id") logger.info(f"Slack bot connected as {self._bot_user_id}") except Exception as e: logger.warning(f"Slack auth_test failed: {e}") logger.info("Starting Slack Socket Mode client...") await self._socket_client.connect() while self._running: await asyncio.sleep(1) async def stop(self) -> None: """Stop the Slack client.""" self._running = False if self._socket_client: try: await self._socket_client.close() except Exception as e: logger.warning(f"Slack socket close failed: {e}") self._socket_client = None async def send(self, msg: OutboundMessage) -> None: """Send a message through Slack.""" if not self._web_client: logger.warning("Slack client not running") return try: slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} thread_ts = slack_meta.get("thread_ts") channel_type = slack_meta.get("channel_type") # Only reply in thread for channel/group messages; DMs don't use threads use_thread = thread_ts and channel_type != "im" await self._web_client.chat_postMessage( channel=msg.chat_id, text=self._convert_markdown(msg.content) or "", thread_ts=thread_ts if use_thread else None, ) except Exception as e: logger.error(f"Error sending Slack message: {e}") async def _on_socket_request( self, client: SocketModeClient, req: SocketModeRequest, ) -> None: """Handle incoming Socket Mode requests.""" if req.type != "events_api": return # Acknowledge right away await client.send_socket_mode_response( SocketModeResponse(envelope_id=req.envelope_id) ) payload = req.payload or {} event = payload.get("event") or {} event_type = event.get("type") # Handle app mentions or plain messages if event_type not in ("message", "app_mention"): return sender_id = event.get("user") chat_id = event.get("channel") # Ignore bot/system messages (any subtype = not a normal user message) if event.get("subtype"): return if self._bot_user_id and sender_id == self._bot_user_id: return # Avoid double-processing: Slack sends both `message` and `app_mention` # for mentions in channels. Prefer `app_mention`. text = event.get("text") or "" if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text: return # Debug: log basic event shape logger.debug( "Slack event: type={} subtype={} user={} channel={} channel_type={} text={}", event_type, event.get("subtype"), sender_id, chat_id, event.get("channel_type"), text[:80], ) if not sender_id or not chat_id: return channel_type = event.get("channel_type") or "" if not self._is_allowed(sender_id, chat_id, channel_type): return if channel_type != "im" and not self._should_respond_in_channel(event_type, text, chat_id): return text = self._strip_bot_mention(text) thread_ts = event.get("thread_ts") or event.get("ts") # Add :eyes: reaction to the triggering message (best-effort) try: if self._web_client and event.get("ts"): await self._web_client.reactions_add( channel=chat_id, name="eyes", timestamp=event.get("ts"), ) except Exception as e: logger.debug(f"Slack reactions_add failed: {e}") await self._handle_message( sender_id=sender_id, chat_id=chat_id, content=text, metadata={ "slack": { "event": event, "thread_ts": thread_ts, "channel_type": channel_type, } }, ) def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: if channel_type == "im": if not self.config.dm.enabled: return False if self.config.dm.policy == "allowlist": return sender_id in self.config.dm.allow_from return True # Group / channel messages if self.config.group_policy == "allowlist": return chat_id in self.config.group_allow_from return True def _should_respond_in_channel(self, event_type: str, text: str, chat_id: str) -> bool: if self.config.group_policy == "open": return True if self.config.group_policy == "mention": if event_type == "app_mention": return True return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text if self.config.group_policy == "allowlist": return chat_id in self.config.group_allow_from return False def _strip_bot_mention(self, text: str) -> str: if not text or not self._bot_user_id: return text return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() # Markdown → Slack mrkdwn formatting rules (order matters: longest markers first) _MD_TO_SLACK = ( (r'(?m)(^|[^\*])\*\*\*(.+?)\*\*\*([^\*]|$)', r'\1*_\2_*\3'), # ***bold italic*** (r'(?m)(^|[^_])___(.+?)___([^_]|$)', r'\1*_\2_*\3'), # ___bold italic___ (r'(?m)(^|[^\*])\*\*(.+?)\*\*([^\*]|$)', r'\1*\2*\3'), # **bold** (r'(?m)(^|[^_])__(.+?)__([^_]|$)', r'\1*\2*\3'), # __bold__ (r'(?m)(^|[^\*])\*(.+?)\*([^\*]|$)', r'\1_\2_\3'), # *italic* (r'(?m)(^|[^~])~~(.+?)~~([^~]|$)', r'\1~\2~\3'), # ~~strike~~ (r'(?m)(^|[^!])\[(.+?)\]\((http.+?)\)', r'\1<\3|\2>'), # [text](url) (r'!\[.+?\]\((http.+?)(?:\s".*?")?\)', r'<\1>'), # ![alt](url) ) _TABLE_RE = re.compile(r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*') def _convert_markdown(self, text: str) -> str: """Convert standard Markdown to Slack mrkdwn format.""" if not text: return text for pattern, repl in self._MD_TO_SLACK: text = re.sub(pattern, repl, text) return self._TABLE_RE.sub(self._convert_table, text) @staticmethod def _convert_table(match: re.Match) -> str: """Convert Markdown table to Slack quote + bullet format.""" lines = [l.strip() for l in match.group(0).strip().split('\n') if l.strip()] if len(lines) < 2: return match.group(0) headers = [h.strip() for h in lines[0].strip('|').split('|')] start = 2 if not re.search(r'[^|\-\s:]', lines[1]) else 1 result: list[str] = [] for line in lines[start:]: cells = [c.strip() for c in line.strip('|').split('|')] cells = (cells + [''] * len(headers))[:len(headers)] if not any(cells): continue result.append(f"> *{headers[0]}*: {cells[0] or '--'}") for i, cell in enumerate(cells[1:], 1): if cell and i < len(headers): result.append(f" \u2022 *{headers[i]}*: {cell}") result.append("") return '\n'.join(result).rstrip()