diff --git a/README.md b/README.md
index 4acaca8..6cde257 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, Email, or QQ — anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slack, Email, or QQ — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -175,6 +175,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Emai
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
| **DingTalk** | Medium (app credentials) |
+| **Slack** | Medium (bot + app tokens) |
| **Email** | Medium (IMAP/SMTP credentials) |
| **QQ** | Easy (app credentials) |
@@ -200,7 +201,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Emai
}
```
-> Get your user ID from `@userinfobot` on Telegram.
+> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`.
+> Copy this value **without the `@` symbol** and paste it into the config file.
+
**3. Run**
@@ -414,6 +417,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
@@ -534,7 +575,6 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
### Security
-> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
| Option | Default | Description |
@@ -633,7 +673,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 a7b1ed5..c63df5e 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -107,6 +107,17 @@ class ChannelManager:
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}")
+
# QQ channel
if self.config.channels.qq.enabled:
try:
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 f31d279..1aae587 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 QQConfig(BaseModel):
"""QQ channel configuration using botpy SDK."""
enabled: bool = False
@@ -93,6 +113,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)
qq: QQConfig = Field(default_factory=QQConfig)
diff --git a/pyproject.toml b/pyproject.toml
index 21b50f0..8662f58 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",
"qq-botpy>=1.0.0",
]