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 5f02142..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, Slack, or Email — anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slack, Email, or QQ — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -177,6 +177,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slac
| **DingTalk** | Medium (app credentials) |
| **Slack** | Medium (bot + app tokens) |
| **Email** | Medium (IMAP/SMTP credentials) |
+| **QQ** | Easy (app credentials) |
Telegram (Recommended)
@@ -338,6 +339,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 b7bb0b2..c63df5e 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -117,6 +117,18 @@ class ChannelManager:
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..e3efb4f
--- /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(c2c_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/config/schema.py b/nanobot/config/schema.py
index 8424cab..1aae587 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -97,6 +97,14 @@ class SlackConfig(BaseModel):
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)
@@ -106,6 +114,7 @@ class ChannelsConfig(BaseModel):
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 aa65ea3..8662f58 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
"lark-oapi>=1.0.0",
"socksio>=1.0.0",
"slack-sdk>=3.26.0",
+ "qq-botpy>=1.0.0",
]
[project.optional-dependencies]