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}")