diff --git a/README.md b/README.md index 4106b2a..186fe35 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!" ## 💬 Chat Apps -Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or Email — anytime, anywhere. +Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slack, or Email — anytime, anywhere. | Channel | Setup | |---------|-------| @@ -175,6 +175,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E | **WhatsApp** | Medium (scan QR) | | **Feishu** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) | +| **Slack** | Medium (bot + app tokens) | | **Email** | Medium (IMAP/SMTP credentials) |
@@ -374,6 +375,44 @@ nanobot gateway
+
+Slack + +Uses **Socket Mode** — no public URL required. + +**1. Create a Slack app** +- Go to [Slack API](https://api.slack.com/apps) → Create New App +- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read` +- Install to your workspace and copy the **Bot Token** (`xoxb-...`) +- **Socket Mode**: Enable it and generate an **App-Level Token** (`xapp-...`) with `connections:write` scope +- **Event Subscriptions**: Subscribe to `message.im`, `message.channels`, `app_mention` + +**2. Configure** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "botToken": "xoxb-...", + "appToken": "xapp-...", + "groupPolicy": "mention" + } + } +} +``` + +> `groupPolicy`: `"mention"` (respond only when @mentioned), `"open"` (respond to all messages), or `"allowlist"` (restrict to specific channels). +> DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. + +**3. Run** + +```bash +nanobot gateway +``` + +
+
Email @@ -592,7 +631,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗 - [ ] **Multi-modal** — See and hear (images, voice, video) - [ ] **Long-term memory** — Never forget important context - [ ] **Better reasoning** — Multi-step planning and reflection -- [ ] **More integrations** — Slack, calendar, and more +- [ ] **More integrations** — Calendar and more - [ ] **Self-improvement** — Learn from feedback and mistakes ### Contributors diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 72ea86a..b764c3d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -245,7 +245,8 @@ class AgentLoop: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content=final_content + content=final_content, + metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 26fa9f3..b7bb0b2 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -106,6 +106,17 @@ class ChannelManager: logger.info("Email channel enabled") except ImportError as e: logger.warning(f"Email 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_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py new file mode 100644 index 0000000..be95dd2 --- /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.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=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() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a1f426e..78046e0 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -571,6 +571,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 aa9729b..8424cab 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -77,6 +77,26 @@ class EmailConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses +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) @@ -85,6 +105,7 @@ class ChannelsConfig(BaseModel): feishu: FeishuConfig = Field(default_factory=FeishuConfig) dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig) email: EmailConfig = Field(default_factory=EmailConfig) + slack: SlackConfig = Field(default_factory=SlackConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 6fda084..aa65ea3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "python-telegram-bot[socks]>=21.0", "lark-oapi>=1.0.0", "socksio>=1.0.0", + "slack-sdk>=3.26.0", ] [project.optional-dependencies]