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]