fix: align QQ channel with BaseChannel conventions, simplify implementation
This commit is contained in:
parent
2c45657b14
commit
a63a44fa79
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,4 +18,4 @@ __pycache__/
|
|||||||
poetry.lock
|
poetry.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
tests/
|
tests/
|
||||||
botpy.log
|
botpy.log
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user