fix: align QQ channel with BaseChannel conventions, simplify implementation

This commit is contained in:
Re-bin 2026-02-09 12:04:34 +00:00
parent 2c45657b14
commit a63a44fa79
2 changed files with 39 additions and 119 deletions

2
.gitignore vendored
View File

@ -18,4 +18,4 @@ __pycache__/
poetry.lock poetry.lock
.pytest_cache/ .pytest_cache/
tests/ tests/
botpy.log botpy.log

View File

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from loguru import logger 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.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.schema import QQConfig from nanobot.config.schema import QQConfig
@ -25,31 +25,28 @@ if TYPE_CHECKING:
from botpy.message import C2CMessage from botpy.message import C2CMessage
def parse_chat_id(chat_id: str) -> tuple[str, str]: def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
"""Parse chat_id into (channel, user_id). """Create a botpy Client subclass bound to the given channel."""
intents = botpy.Intents(c2c_message=True)
Args: class _Bot(botpy.Client):
chat_id: Format "channel:user_id", e.g. "qq:openid_xxx" def __init__(self):
super().__init__(intents=intents)
Returns: async def on_ready(self):
Tuple of (channel, user_id) logger.info(f"QQ bot ready: {self.robot.name}")
"""
if ":" not in chat_id: async def on_c2c_message_create(self, message: "C2CMessage"):
raise ValueError(f"Invalid chat_id format: {chat_id}") await channel._on_message(message)
channel, user_id = chat_id.split(":", 1)
return channel, user_id async def on_direct_message_create(self, message):
await channel._on_message(message)
return _Bot
class QQChannel(BaseChannel): class QQChannel(BaseChannel):
""" """QQ channel using botpy SDK with WebSocket connection."""
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" name = "qq"
@ -57,79 +54,43 @@ class QQChannel(BaseChannel):
super().__init__(config, bus) super().__init__(config, bus)
self.config: QQConfig = config self.config: QQConfig = config
self._client: "botpy.Client | None" = None 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 self._bot_task: asyncio.Task | None = None
async def start(self) -> None: async def start(self) -> None:
"""Start the QQ bot.""" """Start the QQ bot."""
if not QQ_AVAILABLE: if not QQ_AVAILABLE:
logger.error("QQ SDK 未安装。请运行:pip install qq-botpy") logger.error("QQ SDK not installed. Run: pip install qq-botpy")
return return
if not self.config.app_id or not self.config.secret: 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 return
self._running = True self._running = True
BotClass = _make_bot_class(self)
self._client = BotClass()
# Create bot client with C2C intents self._bot_task = asyncio.create_task(self._run_bot())
intents = botpy.Intents.all() logger.info("QQ bot started (C2C private message)")
logger.info(f"QQ Intents 配置值: {intents.value}")
# Create custom bot class with message handlers async def _run_bot(self) -> None:
class QQBot(botpy.Client): """Run the bot connection."""
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: 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: except Exception as e:
logger.error( logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}")
f"QQ 鉴权失败,请检查 AppID 和 Secret 是否正确。"
f"访问 q.qq.com 获取凭证。错误: {e}"
)
self._running = False self._running = False
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the QQ bot.""" """Stop the QQ bot."""
self._running = False self._running = False
if self._bot_task: if self._bot_task:
self._bot_task.cancel() self._bot_task.cancel()
try: try:
await self._bot_task await self._bot_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
logger.info("QQ bot stopped") logger.info("QQ bot stopped")
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
@ -137,75 +98,34 @@ class QQChannel(BaseChannel):
if not self._client: if not self._client:
logger.warning("QQ client not initialized") logger.warning("QQ client not initialized")
return return
try: 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( await self._client.api.post_c2c_message(
openid=user_id, openid=msg.chat_id,
msg_type=0, msg_type=0,
content=msg.content, 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: except Exception as e:
logger.error(f"Error sending QQ message: {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.""" """Handle incoming message from QQ."""
try: try:
# Message deduplication using deque with maxlen # Dedup by message ID
message_id = data.id if data.id in self._processed_ids:
if message_id in self._processed_message_ids:
logger.debug(f"Duplicate message {message_id}, skipping")
return 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 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_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown'))
user_name = getattr(author, 'username', None) or 'unknown' content = (data.content or "").strip()
# 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: if not content:
logger.debug(f"Empty message from {user_id}, skipping")
return return
# Publish to message bus await self._handle_message(
msg = InboundMessage(
channel=self.name,
sender_id=user_id, sender_id=user_id,
chat_id=chat_id, chat_id=user_id,
content=content, content=content,
metadata={ metadata={"message_id": data.id},
"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: except Exception as e:
logger.error(f"Error handling QQ message: {e}") logger.error(f"Error handling QQ message: {e}")