diff --git a/.gitignore b/.gitignore index 55338f7..36dbfc2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ docs/ __pycache__/ poetry.lock .pytest_cache/ -tests/ \ No newline at end of file +tests/ +botpy.log diff --git a/README.md b/README.md index 8f7c1a2..eb2ff7f 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. -๐Ÿ“ Real-time line count: **3,479 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News +- **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! @@ -166,7 +167,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, Email, or QQ โ€” anytime, anywhere. | Channel | Setup | |---------|-------| @@ -175,7 +176,9 @@ 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) | +| **QQ** | Easy (app credentials) |
Telegram (Recommended) @@ -199,7 +202,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E } ``` -> 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** @@ -335,6 +340,49 @@ nanobot gateway
+
+QQ (QQๅ•่Š) + +Uses **botpy SDK** with WebSocket โ€” no public IP required. Currently supports **private messages only**. + +**1. Register & create bot** +- Visit [QQ Open Platform](https://q.qq.com) โ†’ Register as a developer (personal or enterprise) +- Create a new bot application +- Go to **ๅผ€ๅ‘่ฎพ็ฝฎ (Developer Settings)** โ†’ copy **AppID** and **AppSecret** + +**2. Set up sandbox for testing** +- In the bot management console, find **ๆฒ™็ฎฑ้…็ฝฎ (Sandbox Config)** +- Under **ๅœจๆถˆๆฏๅˆ—่กจ้…็ฝฎ**, click **ๆทปๅŠ ๆˆๅ‘˜** and add your own QQ number +- Once added, scan the bot's QR code with mobile QQ โ†’ open the bot profile โ†’ tap "ๅ‘ๆถˆๆฏ" to start chatting + +**3. Configure** + +> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot. +> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow. + +```json +{ + "channels": { + "qq": { + "enabled": true, + "appId": "YOUR_APP_ID", + "secret": "YOUR_APP_SECRET", + "allowFrom": [] + } + } +} +``` + +**4. Run** + +```bash +nanobot gateway +``` + +Now send a message to the bot from QQ โ€” it should respond! + +
+
DingTalk (้’‰้’‰) @@ -374,20 +422,60 @@ 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 -Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit consent before accessing mailbox data. +Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** โ€” like a personal email assistant. **1. Get credentials (Gmail example)** -- Enable 2-Step Verification in Google account security -- Create an [App Password](https://myaccount.google.com/apppasswords) +- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`) +- Enable 2-Step Verification โ†’ Create an [App Password](https://myaccount.google.com/apppasswords) - Use this app password for both IMAP and SMTP **2. Configure** -> [!TIP] -> Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. +> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate โ€” set `false` to fully disable. +> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders. +> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly. +> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. ```json { @@ -397,23 +485,19 @@ Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit con "consentGranted": true, "imapHost": "imap.gmail.com", "imapPort": 993, - "imapUsername": "you@gmail.com", + "imapUsername": "my-nanobot@gmail.com", "imapPassword": "your-app-password", - "imapUseSsl": true, "smtpHost": "smtp.gmail.com", "smtpPort": 587, - "smtpUsername": "you@gmail.com", + "smtpUsername": "my-nanobot@gmail.com", "smtpPassword": "your-app-password", - "smtpUseTls": true, - "fromAddress": "you@gmail.com", - "allowFrom": ["trusted@example.com"] + "fromAddress": "my-nanobot@gmail.com", + "allowFrom": ["your-real-email@gmail.com"] } } } ``` -> `consentGranted`: Must be `true` to allow mailbox access. Set to `false` to disable reading and sending entirely. -> `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific sender addresses. **3. Run** @@ -494,7 +578,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 | @@ -593,7 +676,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..c63df5e 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -106,6 +106,29 @@ 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}") + + # QQ channel + if self.config.channels.qq.enabled: + try: + from nanobot.channels.qq import QQChannel + self.channels["qq"] = QQChannel( + self.config.channels.qq, + self.bus, + ) + logger.info("QQ channel enabled") + except ImportError as e: + logger.warning(f"QQ 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/qq.py b/nanobot/channels/qq.py new file mode 100644 index 0000000..5964d30 --- /dev/null +++ b/nanobot/channels/qq.py @@ -0,0 +1,131 @@ +"""QQ channel implementation using botpy SDK.""" + +import asyncio +from collections import deque +from typing import TYPE_CHECKING + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import QQConfig + +try: + import botpy + from botpy.message import C2CMessage + + QQ_AVAILABLE = True +except ImportError: + QQ_AVAILABLE = False + botpy = None + C2CMessage = None + +if TYPE_CHECKING: + from botpy.message import C2CMessage + + +def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": + """Create a botpy Client subclass bound to the given channel.""" + intents = botpy.Intents(public_messages=True, direct_message=True) + + class _Bot(botpy.Client): + def __init__(self): + super().__init__(intents=intents) + + async def on_ready(self): + logger.info(f"QQ bot ready: {self.robot.name}") + + async def on_c2c_message_create(self, message: "C2CMessage"): + await channel._on_message(message) + + async def on_direct_message_create(self, message): + await channel._on_message(message) + + return _Bot + + +class QQChannel(BaseChannel): + """QQ channel using botpy SDK with WebSocket connection.""" + + name = "qq" + + def __init__(self, config: QQConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: QQConfig = config + self._client: "botpy.Client | None" = None + self._processed_ids: deque = deque(maxlen=1000) + self._bot_task: asyncio.Task | None = None + + async def start(self) -> None: + """Start the QQ bot.""" + if not QQ_AVAILABLE: + logger.error("QQ SDK not installed. Run: pip install qq-botpy") + return + + if not self.config.app_id or not self.config.secret: + logger.error("QQ app_id and secret not configured") + return + + self._running = True + BotClass = _make_bot_class(self) + self._client = BotClass() + + self._bot_task = asyncio.create_task(self._run_bot()) + logger.info("QQ bot started (C2C private message)") + + async def _run_bot(self) -> None: + """Run the bot connection.""" + try: + await self._client.start(appid=self.config.app_id, secret=self.config.secret) + except Exception as e: + logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}") + self._running = False + + async def stop(self) -> None: + """Stop the QQ bot.""" + self._running = False + if self._bot_task: + self._bot_task.cancel() + try: + await self._bot_task + except asyncio.CancelledError: + pass + logger.info("QQ bot stopped") + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through QQ.""" + if not self._client: + logger.warning("QQ client not initialized") + return + try: + await self._client.api.post_c2c_message( + openid=msg.chat_id, + msg_type=0, + content=msg.content, + ) + except Exception as e: + logger.error(f"Error sending QQ message: {e}") + + async def _on_message(self, data: "C2CMessage") -> None: + """Handle incoming message from QQ.""" + try: + # Dedup by message ID + if data.id in self._processed_ids: + return + self._processed_ids.append(data.id) + + author = data.author + user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) + content = (data.content or "").strip() + if not content: + return + + await self._handle_message( + sender_id=user_id, + chat_id=user_id, + content=content, + metadata={"message_id": data.id}, + ) + except Exception as e: + logger.error(f"Error handling QQ message: {e}") 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 bbdf79c..1ee2332 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 7da5e75..f4a7910 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -77,6 +77,34 @@ 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 + app_id: str = "" # ๆœบๅ™จไบบ ID (AppID) from q.qq.com + secret: str = "" # ๆœบๅ™จไบบๅฏ†้’ฅ (AppSecret) from q.qq.com + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) @@ -85,6 +113,8 @@ 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) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index e3076c4..b044932 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ 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", ] [project.optional-dependencies]