diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bfe6e89..ac24016 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -220,7 +220,8 @@ class AgentLoop: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content=final_content + content=final_content, + metadata=msg.metadata or {}, ) async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 73c3334..d49d3b1 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -55,6 +55,17 @@ class ChannelManager: logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") + + # Slack channel + if self.config.channels.slack.enabled: + try: + from nanobot.channels.slack import SlackChannel + self.channels["slack"] = SlackChannel( + self.config.channels.slack, self.bus + ) + logger.info("Slack channel enabled") + except ImportError as e: + logger.warning(f"Slack channel not available: {e}") async def start_all(self) -> None: """Start WhatsApp channel and the outbound dispatcher.""" diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py new file mode 100644 index 0000000..32abe3b --- /dev/null +++ b/nanobot/channels/slack.py @@ -0,0 +1,205 @@ +"""Slack channel implementation using Socket Mode.""" + +import asyncio +import re +from typing import Any + +from loguru import logger +from slack_sdk.socket_mode.aiohttp 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=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 to prevent loops + if event.get("subtype") == "bot_message" or 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() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c2241fb..1dd91a9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -379,6 +379,15 @@ def channels_status(): tg_config ) + # Slack + slack = config.channels.slack + slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]" + table.add_row( + "Slack", + "✓" if slack.enabled else "✗", + slack_config + ) + console.print(table) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4c34834..3575454 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -19,10 +19,31 @@ class TelegramConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames +class SlackDMConfig(BaseModel): + """Slack DM policy configuration.""" + enabled: bool = True + policy: str = "open" # "open" or "allowlist" + allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs + + +class SlackConfig(BaseModel): + """Slack channel configuration.""" + enabled: bool = False + mode: str = "socket" # "socket" supported + webhook_path: str = "/slack/events" + bot_token: str = "" # xoxb-... + app_token: str = "" # xapp-... + user_token_read_only: bool = True + group_policy: str = "open" # "open", "mention", "allowlist" + group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist + dm: SlackDMConfig = Field(default_factory=SlackDMConfig) + + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) + slack: SlackConfig = Field(default_factory=SlackConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index d578a08..5d4dec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "rich>=13.0.0", "croniter>=2.0.0", "python-telegram-bot>=21.0", + "slack-sdk>=3.26.0", ] [project.optional-dependencies]