"""Slack channel implementation using Socket Mode.""" import asyncio import re from typing import Any from loguru import logger from pydantic import Field from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.web.async_client import AsyncWebClient from slackify_markdown import slackify_markdown from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Base class SlackDMConfig(Base): """Slack DM policy configuration.""" enabled: bool = True policy: str = "open" allow_from: list[str] = Field(default_factory=list) class SlackConfig(Base): """Slack channel configuration.""" enabled: bool = False mode: str = "socket" webhook_path: str = "/slack/events" bot_token: str = "" app_token: str = "" user_token_read_only: bool = True reply_in_thread: bool = True react_emoji: str = "eyes" done_emoji: str = "white_check_mark" allow_from: list[str] = Field(default_factory=list) group_policy: str = "mention" group_allow_from: list[str] = Field(default_factory=list) dm: SlackDMConfig = Field(default_factory=SlackDMConfig) class SlackChannel(BaseChannel): """Slack channel using Socket Mode.""" name = "slack" display_name = "Slack" _SLACK_ID_RE = re.compile(r"^[CDGUW][A-Z0-9]{2,}$") _SLACK_CHANNEL_REF_RE = re.compile(r"^<#([A-Z0-9]+)(?:\|[^>]+)?>$") _SLACK_USER_REF_RE = re.compile(r"^<@([A-Z0-9]+)(?:\|[^>]+)?>$") @classmethod def default_config(cls) -> dict[str, Any]: return SlackConfig().model_dump(by_alias=True) def __init__(self, config: Any, bus: MessageBus): if isinstance(config, dict): config = SlackConfig.model_validate(config) 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 self._target_cache: dict[str, str] = {} 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("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("Slack bot connected as {}", self._bot_user_id) except Exception as e: logger.warning("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("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: target_chat_id = await self._resolve_target_chat_id(msg.chat_id) slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} thread_ts = slack_meta.get("thread_ts") channel_type = slack_meta.get("channel_type") origin_chat_id = str((slack_meta.get("event", {}) or {}).get("channel") or msg.chat_id) # Slack DMs don't use threads; channel/group replies may keep thread_ts. thread_ts_param = ( thread_ts if thread_ts and channel_type != "im" and target_chat_id == origin_chat_id else None ) # Slack rejects empty text payloads. Keep media-only messages media-only, # but send a single blank message when the bot has no text or files to send. if msg.content or not (msg.media or []): await self._web_client.chat_postMessage( channel=target_chat_id, text=self._to_mrkdwn(msg.content) if msg.content else " ", thread_ts=thread_ts_param, ) for media_path in msg.media or []: try: await self._web_client.files_upload_v2( channel=target_chat_id, file=media_path, thread_ts=thread_ts_param, ) except Exception as e: logger.error("Failed to upload file {}: {}", media_path, e) # Update reaction emoji when the final (non-progress) response is sent if not (msg.metadata or {}).get("_progress"): event = slack_meta.get("event", {}) await self._update_react_emoji(origin_chat_id, event.get("ts")) except Exception as e: logger.error("Error sending Slack message: {}", e) raise async def _resolve_target_chat_id(self, target: str) -> str: """Resolve human-friendly Slack targets to concrete IDs when needed.""" if not self._web_client: return target target = target.strip() if not target: return target if match := self._SLACK_CHANNEL_REF_RE.fullmatch(target): return match.group(1) if match := self._SLACK_USER_REF_RE.fullmatch(target): return await self._open_dm_for_user(match.group(1)) if self._SLACK_ID_RE.fullmatch(target): if target.startswith(("U", "W")): return await self._open_dm_for_user(target) return target if target.startswith("#"): return await self._resolve_channel_name(target[1:]) if target.startswith("@"): return await self._resolve_user_handle(target[1:]) try: return await self._resolve_channel_name(target) except ValueError: return await self._resolve_user_handle(target) async def _resolve_channel_name(self, name: str) -> str: normalized = self._normalize_target_name(name) if not normalized: raise ValueError("Slack target channel name is empty") cache_key = f"channel:{normalized}" if cache_key in self._target_cache: return self._target_cache[cache_key] cursor: str | None = None while True: response = await self._web_client.conversations_list( types="public_channel,private_channel", exclude_archived=True, limit=200, cursor=cursor, ) for channel in response.get("channels", []): if self._normalize_target_name(str(channel.get("name") or "")) == normalized: channel_id = str(channel.get("id") or "") if channel_id: self._target_cache[cache_key] = channel_id return channel_id cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip() if not cursor: break raise ValueError( f"Slack channel '{name}' was not found. Use a joined channel name like " f"'#general' or a concrete channel ID." ) async def _resolve_user_handle(self, handle: str) -> str: normalized = self._normalize_target_name(handle) if not normalized: raise ValueError("Slack target user handle is empty") cache_key = f"user:{normalized}" if cache_key in self._target_cache: return self._target_cache[cache_key] cursor: str | None = None while True: response = await self._web_client.users_list(limit=200, cursor=cursor) for member in response.get("members", []): if self._member_matches_handle(member, normalized): user_id = str(member.get("id") or "") if not user_id: continue dm_id = await self._open_dm_for_user(user_id) self._target_cache[cache_key] = dm_id return dm_id cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip() if not cursor: break raise ValueError( f"Slack user '{handle}' was not found. Use '@name' or a concrete DM/channel ID." ) async def _open_dm_for_user(self, user_id: str) -> str: response = await self._web_client.conversations_open(users=user_id) channel_id = str(((response.get("channel") or {}).get("id")) or "") if not channel_id: raise ValueError(f"Slack DM target for user '{user_id}' could not be opened.") return channel_id @staticmethod def _normalize_target_name(value: str) -> str: return value.strip().lstrip("#@").lower() @classmethod def _member_matches_handle(cls, member: dict[str, Any], normalized: str) -> bool: profile = member.get("profile") or {} candidates = { str(member.get("name") or ""), str(profile.get("display_name") or ""), str(profile.get("display_name_normalized") or ""), str(profile.get("real_name") or ""), str(profile.get("real_name_normalized") or ""), } return normalized in {cls._normalize_target_name(candidate) for candidate in candidates if candidate} 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") if self.config.reply_in_thread and not thread_ts: thread_ts = 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=self.config.react_emoji, timestamp=event.get("ts"), ) except Exception as e: logger.debug("Slack reactions_add failed: {}", e) # Thread-scoped session key for channel/group messages session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None try: 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, }, }, session_key=session_key, ) except Exception: logger.exception("Error handling Slack message from {}", sender_id) async def _update_react_emoji(self, chat_id: str, ts: str | None) -> None: """Remove the in-progress reaction and optionally add a done reaction.""" if not self._web_client or not ts: return try: await self._web_client.reactions_remove( channel=chat_id, name=self.config.react_emoji, timestamp=ts, ) except Exception as e: logger.debug("Slack reactions_remove failed: {}", e) if self.config.done_emoji: try: await self._web_client.reactions_add( channel=chat_id, name=self.config.done_emoji, timestamp=ts, ) except Exception as e: logger.debug("Slack done reaction failed: {}", e) 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() _TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*") _CODE_FENCE_RE = re.compile(r"```[\s\S]*?```") _INLINE_CODE_RE = re.compile(r"`[^`]+`") _LEFTOVER_BOLD_RE = re.compile(r"\*\*(.+?)\*\*") _LEFTOVER_HEADER_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE) _BARE_URL_RE = re.compile(r"(? str: """Convert Markdown to Slack mrkdwn, including tables.""" if not text: return "" text = cls._TABLE_RE.sub(cls._convert_table, text) return cls._fixup_mrkdwn(slackify_markdown(text)) @classmethod def _fixup_mrkdwn(cls, text: str) -> str: """Fix markdown artifacts that slackify_markdown misses.""" code_blocks: list[str] = [] def _save_code(m: re.Match) -> str: code_blocks.append(m.group(0)) return f"\x00CB{len(code_blocks) - 1}\x00" text = cls._CODE_FENCE_RE.sub(_save_code, text) text = cls._INLINE_CODE_RE.sub(_save_code, text) text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text) text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text) text = cls._BARE_URL_RE.sub(lambda m: m.group(0).replace("&", "&"), text) for i, block in enumerate(code_blocks): text = text.replace(f"\x00CB{i}\x00", block) return text @staticmethod def _convert_table(match: re.Match) -> str: """Convert a Markdown table to a Slack-readable list.""" lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()] if len(lines) < 2: return match.group(0) headers = [h.strip() for h in lines[0].strip("|").split("|")] start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1 rows: list[str] = [] for line in lines[start:]: cells = [c.strip() for c in line.strip("|").split("|")] cells = (cells + [""] * len(headers))[: len(headers)] parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]] if parts: rows.append(" ยท ".join(parts)) return "\n".join(rows)