From 051e396a8a5d07340f00e8722b91f2a53e9c5c23 Mon Sep 17 00:00:00 2001 From: Kamal Date: Wed, 4 Feb 2026 23:26:20 +0530 Subject: [PATCH 01/14] feat: add Slack channel support --- nanobot/agent/loop.py | 3 +- nanobot/channels/manager.py | 11 ++ nanobot/channels/slack.py | 205 ++++++++++++++++++++++++++++++++++++ nanobot/cli/commands.py | 9 ++ nanobot/config/schema.py | 21 ++++ pyproject.toml | 1 + 6 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 nanobot/channels/slack.py 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] From 26c506c413e5ff77e13f49509988a98fa82fdb2a Mon Sep 17 00:00:00 2001 From: JakeRowe19 <117069245+JakeRowe19@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:49:43 +0300 Subject: [PATCH 02/14] Update README.md Fixed unclear note for getting Telegram user id. /issues/74 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f7c1a2..335eae0 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,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** From 34dc933fce092d8bd8de8df2c543276d7691156f Mon Sep 17 00:00:00 2001 From: yinwm Date: Mon, 9 Feb 2026 15:47:55 +0800 Subject: [PATCH 03/14] feat: add QQ channel integration with botpy SDK Add official QQ platform support using botpy SDK with WebSocket connection. Features: - C2C (private message) support via QQ Open Platform - WebSocket-based bot connection (no public IP required) - Message deduplication with efficient deque-based LRU cache - User whitelist support via allow_from configuration - Clean async architecture using single event loop Changes: - Add QQChannel implementation in nanobot/channels/qq.py - Add QQConfig schema with appId and secret fields - Register QQ channel in ChannelManager - Update README with QQ setup instructions - Add qq-botpy dependency to pyproject.toml - Add botpy.log to .gitignore Setup: 1. Get AppID and Secret from q.qq.com 2. Configure in ~/.nanobot/config.json: { "channels": { "qq": { "enabled": true, "appId": "YOUR_APP_ID", "secret": "YOUR_APP_SECRET", "allowFrom": [] } } } 3. Run: nanobot gateway Note: Group chat support will be added in future updates. Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 3 +- README.md | 42 ++++++- nanobot/channels/manager.py | 12 ++ nanobot/channels/qq.py | 211 ++++++++++++++++++++++++++++++++++++ nanobot/config/schema.py | 9 ++ pyproject.toml | 1 + 6 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 nanobot/channels/qq.py diff --git a/.gitignore b/.gitignore index 55338f7..4e58574 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 \ No newline at end of file diff --git a/README.md b/README.md index 8f7c1a2..4acaca8 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, Email, or QQ — anytime, anywhere. | Channel | Setup | |---------|-------| @@ -176,6 +176,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E | **Feishu** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) | | **Email** | Medium (IMAP/SMTP credentials) | +| **QQ** | Easy (app credentials) |
Telegram (Recommended) @@ -335,6 +336,45 @@ nanobot gateway
+
+QQ (QQ私聊) + +Uses **botpy SDK** with WebSocket — no public IP required. + +**1. Create a QQ bot** +- Visit [QQ Open Platform](https://q.qq.com) +- Create a new bot application +- Get **AppID** and **Secret** from "Developer Settings" + +**2. Configure** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "appId": "YOUR_APP_ID", + "secret": "YOUR_APP_SECRET", + "allowFrom": [] + } + } +} +``` + +> `allowFrom`: Leave empty for public access, or add user openids to restrict access. +> Example: `"allowFrom": ["user_openid_1", "user_openid_2"]` + +**3. Run** + +```bash +nanobot gateway +``` + +> [!TIP] +> QQ bot currently supports **private messages only**. Group chat support coming soon! + +
+
DingTalk (钉钉) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 26fa9f3..a7b1ed5 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -106,6 +106,18 @@ class ChannelManager: logger.info("Email channel enabled") except ImportError as e: logger.warning(f"Email 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..98ca883 --- /dev/null +++ b/nanobot/channels/qq.py @@ -0,0 +1,211 @@ +"""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 InboundMessage, 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 parse_chat_id(chat_id: str) -> tuple[str, str]: + """Parse chat_id into (channel, user_id). + + Args: + chat_id: Format "channel:user_id", e.g. "qq:openid_xxx" + + Returns: + Tuple of (channel, user_id) + """ + if ":" not in chat_id: + raise ValueError(f"Invalid chat_id format: {chat_id}") + channel, user_id = chat_id.split(":", 1) + return channel, user_id + + +class QQChannel(BaseChannel): + """ + QQ channel using botpy SDK with WebSocket connection. + + Uses botpy SDK to connect to QQ Open Platform (q.qq.com). + + Requires: + - App ID and Secret from q.qq.com + - Robot capability enabled + """ + + 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_message_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 未安装。请运行:pip install qq-botpy") + return + + if not self.config.app_id or not self.config.secret: + logger.error("QQ app_id 和 secret 未配置") + return + + self._running = True + + # Create bot client with C2C intents + intents = botpy.Intents.all() + logger.info(f"QQ Intents 配置值: {intents.value}") + + # Create custom bot class with message handlers + class QQBot(botpy.Client): + def __init__(self, channel): + super().__init__(intents=intents) + self.channel = channel + + async def on_ready(self): + """Called when bot is ready.""" + logger.info(f"QQ bot ready: {self.robot.name}") + + async def on_c2c_message_create(self, message: "C2CMessage"): + """Handle C2C (Client to Client) messages - private chat.""" + await self.channel._on_message(message, "c2c") + + async def on_direct_message_create(self, message): + """Handle direct messages - alternative event name.""" + await self.channel._on_message(message, "direct") + + # TODO: Group message support - implement in future PRD + # async def on_group_at_message_create(self, message): + # """Handle group @ messages.""" + # pass + + self._client = QQBot(self) + + # Start bot - use create_task to run concurrently + self._bot_task = asyncio.create_task( + self._run_bot_with_retry(self.config.app_id, self.config.secret) + ) + + logger.info("QQ bot started with C2C (private message) support") + + async def _run_bot_with_retry(self, app_id: str, secret: str) -> None: + """Run bot with error handling.""" + try: + await self._client.start(appid=app_id, secret=secret) + except Exception as e: + logger.error( + f"QQ 鉴权失败,请检查 AppID 和 Secret 是否正确。" + f"访问 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: + # Parse chat_id format: qq:{user_id} + channel, user_id = parse_chat_id(msg.chat_id) + + if channel != "qq": + logger.warning(f"Invalid channel in chat_id: {msg.chat_id}") + return + + # Send private message using botpy API + await self._client.api.post_c2c_message( + openid=user_id, + msg_type=0, + content=msg.content, + ) + logger.debug(f"QQ message sent to {msg.chat_id}") + + except ValueError as e: + logger.error(f"Invalid chat_id format: {e}") + except Exception as e: + logger.error(f"Error sending QQ message: {e}") + + async def _on_message(self, data: "C2CMessage", msg_type: str) -> None: + """Handle incoming message from QQ.""" + try: + # Message deduplication using deque with maxlen + message_id = data.id + if message_id in self._processed_message_ids: + logger.debug(f"Duplicate message {message_id}, skipping") + return + + self._processed_message_ids.append(message_id) + + # Extract user ID and chat ID from message + author = data.author + # Try different possible field names for user ID + user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) + user_name = getattr(author, 'username', None) or 'unknown' + + # For C2C messages, chat_id is the user's ID + chat_id = f"qq:{user_id}" + + # Check allow_from list (if configured) + if self.config.allow_from and user_id not in self.config.allow_from: + logger.info(f"User {user_id} not in allow_from list") + return + + # Get message content + content = data.content or "" + + if not content: + logger.debug(f"Empty message from {user_id}, skipping") + return + + # Publish to message bus + msg = InboundMessage( + channel=self.name, + sender_id=user_id, + chat_id=chat_id, + content=content, + metadata={ + "message_id": message_id, + "user_name": user_name, + "msg_type": msg_type, + }, + ) + await self.bus.publish_inbound(msg) + + logger.info(f"Received QQ message from {user_id} ({msg_type}): {content[:50]}") + + except Exception as e: + logger.error(f"Error handling QQ message: {e}") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index aa9729b..f31d279 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -77,6 +77,14 @@ class EmailConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses +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 +93,7 @@ class ChannelsConfig(BaseModel): feishu: FeishuConfig = Field(default_factory=FeishuConfig) dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig) email: EmailConfig = Field(default_factory=EmailConfig) + qq: QQConfig = Field(default_factory=QQConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 6fda084..21b50f0 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", + "qq-botpy>=1.0.0", ] [project.optional-dependencies] From 7ffd90aa3b519278ca3eda0ee2ab2a0bba430c98 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 10:59:16 +0000 Subject: [PATCH 04/14] docs: update email channel tips --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 8f7c1a2..4106b2a 100644 --- a/README.md +++ b/README.md @@ -494,7 +494,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 | From f3ab8066a70c72dfc9788f3f1c6ba912456133cd Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 11:39:13 +0000 Subject: [PATCH 05/14] fix: use websockets backend, simplify subtype check, add Slack docs --- README.md | 43 +++++++++++++++++++++++++++++++++++++-- nanobot/agent/loop.py | 2 +- nanobot/channels/slack.py | 6 +++--- 3 files changed, 45 insertions(+), 6 deletions(-) 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 64c95ba..b764c3d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -246,7 +246,7 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content=final_content, - metadata=msg.metadata or {}, + 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/slack.py b/nanobot/channels/slack.py index 32abe3b..be95dd2 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -5,7 +5,7 @@ import re from typing import Any from loguru import logger -from slack_sdk.socket_mode.aiohttp import SocketModeClient +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 @@ -115,8 +115,8 @@ class SlackChannel(BaseChannel): 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"): + # 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 From a63a44fa798aa86a3f6a79d12db2e38700d4e068 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 12:04:34 +0000 Subject: [PATCH 06/14] fix: align QQ channel with BaseChannel conventions, simplify implementation --- .gitignore | 2 +- nanobot/channels/qq.py | 156 ++++++++++------------------------------- 2 files changed, 39 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 4e58574..36dbfc2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ __pycache__/ poetry.lock .pytest_cache/ tests/ -botpy.log \ No newline at end of file +botpy.log diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 98ca883..e3efb4f 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from loguru import logger -from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import QQConfig @@ -25,31 +25,28 @@ if TYPE_CHECKING: from botpy.message import C2CMessage -def parse_chat_id(chat_id: str) -> tuple[str, str]: - """Parse chat_id into (channel, user_id). +def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": + """Create a botpy Client subclass bound to the given channel.""" + intents = botpy.Intents(c2c_message=True) - Args: - chat_id: Format "channel:user_id", e.g. "qq:openid_xxx" + class _Bot(botpy.Client): + def __init__(self): + super().__init__(intents=intents) - Returns: - Tuple of (channel, user_id) - """ - if ":" not in chat_id: - raise ValueError(f"Invalid chat_id format: {chat_id}") - channel, user_id = chat_id.split(":", 1) - return channel, user_id + 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. - - Uses botpy SDK to connect to QQ Open Platform (q.qq.com). - - Requires: - - App ID and Secret from q.qq.com - - Robot capability enabled - """ + """QQ channel using botpy SDK with WebSocket connection.""" name = "qq" @@ -57,79 +54,43 @@ class QQChannel(BaseChannel): super().__init__(config, bus) self.config: QQConfig = config self._client: "botpy.Client | None" = None - self._processed_message_ids: deque = deque(maxlen=1000) + 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 未安装。请运行:pip install qq-botpy") + 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 和 secret 未配置") + logger.error("QQ app_id and secret not configured") return self._running = True + BotClass = _make_bot_class(self) + self._client = BotClass() - # Create bot client with C2C intents - intents = botpy.Intents.all() - logger.info(f"QQ Intents 配置值: {intents.value}") + self._bot_task = asyncio.create_task(self._run_bot()) + logger.info("QQ bot started (C2C private message)") - # Create custom bot class with message handlers - class QQBot(botpy.Client): - def __init__(self, channel): - super().__init__(intents=intents) - self.channel = channel - - async def on_ready(self): - """Called when bot is ready.""" - logger.info(f"QQ bot ready: {self.robot.name}") - - async def on_c2c_message_create(self, message: "C2CMessage"): - """Handle C2C (Client to Client) messages - private chat.""" - await self.channel._on_message(message, "c2c") - - async def on_direct_message_create(self, message): - """Handle direct messages - alternative event name.""" - await self.channel._on_message(message, "direct") - - # TODO: Group message support - implement in future PRD - # async def on_group_at_message_create(self, message): - # """Handle group @ messages.""" - # pass - - self._client = QQBot(self) - - # Start bot - use create_task to run concurrently - self._bot_task = asyncio.create_task( - self._run_bot_with_retry(self.config.app_id, self.config.secret) - ) - - logger.info("QQ bot started with C2C (private message) support") - - async def _run_bot_with_retry(self, app_id: str, secret: str) -> None: - """Run bot with error handling.""" + async def _run_bot(self) -> None: + """Run the bot connection.""" try: - await self._client.start(appid=app_id, secret=secret) + await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.error( - f"QQ 鉴权失败,请检查 AppID 和 Secret 是否正确。" - f"访问 q.qq.com 获取凭证。错误: {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: @@ -137,75 +98,34 @@ class QQChannel(BaseChannel): if not self._client: logger.warning("QQ client not initialized") return - try: - # Parse chat_id format: qq:{user_id} - channel, user_id = parse_chat_id(msg.chat_id) - - if channel != "qq": - logger.warning(f"Invalid channel in chat_id: {msg.chat_id}") - return - - # Send private message using botpy API await self._client.api.post_c2c_message( - openid=user_id, + openid=msg.chat_id, msg_type=0, content=msg.content, ) - logger.debug(f"QQ message sent to {msg.chat_id}") - - except ValueError as e: - logger.error(f"Invalid chat_id format: {e}") except Exception as e: logger.error(f"Error sending QQ message: {e}") - async def _on_message(self, data: "C2CMessage", msg_type: str) -> None: + async def _on_message(self, data: "C2CMessage") -> None: """Handle incoming message from QQ.""" try: - # Message deduplication using deque with maxlen - message_id = data.id - if message_id in self._processed_message_ids: - logger.debug(f"Duplicate message {message_id}, skipping") + # Dedup by message ID + if data.id in self._processed_ids: return + self._processed_ids.append(data.id) - self._processed_message_ids.append(message_id) - - # Extract user ID and chat ID from message author = data.author - # Try different possible field names for user ID user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) - user_name = getattr(author, 'username', None) or 'unknown' - - # For C2C messages, chat_id is the user's ID - chat_id = f"qq:{user_id}" - - # Check allow_from list (if configured) - if self.config.allow_from and user_id not in self.config.allow_from: - logger.info(f"User {user_id} not in allow_from list") - return - - # Get message content - content = data.content or "" - + content = (data.content or "").strip() if not content: - logger.debug(f"Empty message from {user_id}, skipping") return - # Publish to message bus - msg = InboundMessage( - channel=self.name, + await self._handle_message( sender_id=user_id, - chat_id=chat_id, + chat_id=user_id, content=content, - metadata={ - "message_id": message_id, - "user_name": user_name, - "msg_type": msg_type, - }, + metadata={"message_id": data.id}, ) - await self.bus.publish_inbound(msg) - - logger.info(f"Received QQ message from {user_id} ({msg_type}): {content[:50]}") - except Exception as e: logger.error(f"Error handling QQ message: {e}") From 1e95f8b486771b99708dbafb94f236939149acba Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 12:07:45 +0000 Subject: [PATCH 07/14] docs: add 9 feb news --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6cde257..d5a1e17 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! From 03d3c69a4ad0f9181965808798d45c52f9126072 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 12:40:24 +0000 Subject: [PATCH 08/14] docs: improve Email channel setup guide --- README.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d5a1e17..7e1f80b 100644 --- a/README.md +++ b/README.md @@ -459,17 +459,19 @@ 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 { @@ -479,23 +481,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** From 4f928e9d2a27879b95b1f16286a7aeeab27feed1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 16:17:35 +0000 Subject: [PATCH 09/14] feat: improve QQ channel setup guide and fix botpy intent flags --- README.md | 28 ++++++++++++++++------------ nanobot/channels/qq.py | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7e1f80b..eb2ff7f 100644 --- a/README.md +++ b/README.md @@ -341,16 +341,24 @@ nanobot gateway
-QQ (QQ私聊) +QQ (QQ单聊) -Uses **botpy SDK** with WebSocket — no public IP required. +Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**. -**1. Create a QQ bot** -- Visit [QQ Open Platform](https://q.qq.com) +**1. Register & create bot** +- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise) - Create a new bot application -- Get **AppID** and **Secret** from "Developer Settings" +- Go to **开发设置 (Developer Settings)** → copy **AppID** and **AppSecret** -**2. Configure** +**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 { @@ -365,17 +373,13 @@ Uses **botpy SDK** with WebSocket — no public IP required. } ``` -> `allowFrom`: Leave empty for public access, or add user openids to restrict access. -> Example: `"allowFrom": ["user_openid_1", "user_openid_2"]` - -**3. Run** +**4. Run** ```bash nanobot gateway ``` -> [!TIP] -> QQ bot currently supports **private messages only**. Group chat support coming soon! +Now send a message to the bot from QQ — it should respond!
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index e3efb4f..5964d30 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": """Create a botpy Client subclass bound to the given channel.""" - intents = botpy.Intents(c2c_message=True) + intents = botpy.Intents(public_messages=True, direct_message=True) class _Bot(botpy.Client): def __init__(self): From ec4340d0d8d2c6667d7977f87cc7bb3f3ffd5b62 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 16:49:13 +0000 Subject: [PATCH 10/14] feat: add App Home step to Slack guide, default groupPolicy to mention --- README.md | 27 +++++++++++++++++---------- nanobot/config/schema.py | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index eb2ff7f..2a6d29d 100644 --- a/README.md +++ b/README.md @@ -428,13 +428,17 @@ nanobot gateway 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` +- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch" +- Pick a name and select your workspace -**2. Configure** +**2. Configure the app** +- **Socket Mode**: Toggle ON → Generate an **App-Level Token** with `connections:write` scope → copy it (`xapp-...`) +- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read` +- **Event Subscriptions**: Toggle ON → Subscribe to bot events: `message.im`, `message.channels`, `app_mention` → Save Changes +- **App Home**: Scroll to **Show Tabs** → Enable **Messages Tab** → Check **"Allow users to send Slash commands and messages from the messages tab"** +- **Install App**: Click **Install to Workspace** → Authorize → copy the **Bot Token** (`xoxb-...`) + +**3. Configure nanobot** ```json { @@ -449,15 +453,18 @@ Uses **Socket Mode** — no public URL required. } ``` -> `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** +**4. Run** ```bash nanobot gateway ``` +DM the bot directly or @mention it in a channel — it should respond! + +> [!TIP] +> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels). +> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. +
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1aae587..fe0259e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -92,7 +92,7 @@ class SlackConfig(BaseModel): bot_token: str = "" # xoxb-... app_token: str = "" # xapp-... user_token_read_only: bool = True - group_policy: str = "open" # "open", "mention", "allowlist" + group_policy: str = "mention" # "mention", "open", "allowlist" group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist dm: SlackDMConfig = Field(default_factory=SlackDMConfig) From fba5345d20b793765582a4f94db3a0e9813349a7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 02:09:31 +0000 Subject: [PATCH 11/14] fix: pass api_key directly to litellm for more robust auth --- nanobot/providers/litellm_provider.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 9d76c2a..dd50ed9 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -132,6 +132,10 @@ class LiteLLMProvider(LLMProvider): # Apply model-specific overrides (e.g. kimi-k2.5 temperature) self._apply_model_overrides(model, kwargs) + # Pass api_key directly — more reliable than env vars alone + if self.api_key: + kwargs["api_key"] = self.api_key + # Pass api_base for custom endpoints if self.api_base: kwargs["api_base"] = self.api_base From fc9dc4b39718860966f3772944a8a994b4bbe40e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 03:00:42 +0000 Subject: [PATCH 12/14] Release v0.1.3.post6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8662f58..63e148d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post5" +version = "0.1.3.post6" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 76e51ca8def96567aef2c893e9c226d92bdc8ba5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 03:07:27 +0000 Subject: [PATCH 13/14] docs: release v0.1.3.post6 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a6d29d..21c8613 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## 📢 News +- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap discussion](https://github.com/HKUDS/nanobot/discussions/431). - **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. @@ -689,7 +690,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗 ### Contributors - + Contributors From a779f8c453299891415ee37924f7c6757ab8f03f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 03:08:17 +0000 Subject: [PATCH 14/14] docs: update release news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 21c8613..8503b6c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## 📢 News -- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap discussion](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check the [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **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.