Files
EvoScientist-Multi/EvoScientist/channels/qq/channel.py
T
X-iZhang c5a4d559a2 Refactor test cases for improved readability and consistency
- Added blank lines for better separation of test cases in multiple test files.
- Reformatted event handling in tests for clarity and consistency.
- Ensured consistent use of multi-line formatting for dictionary arguments in event handling.
- Improved assertions and test descriptions for better understanding.
- Updated test cases across various modules including test_stream_state, test_stream_utils, test_summarization, test_thread_selector, test_tool_error_handler, test_tui_widgets, test_ui_runtime, and test_wechat_channel.
2026-03-15 21:12:51 +00:00

283 lines
9.8 KiB
Python

"""QQ channel implementation using botpy SDK."""
import asyncio
import logging
from collections import deque
from dataclasses import dataclass
from datetime import datetime
from ..base import Channel, RawIncoming, ChannelError
from ..capabilities import QQ as QQ_CAPS
from ..config import BaseChannelConfig
logger = logging.getLogger(__name__)
try:
import botpy
from botpy.message import C2CMessage, GroupMessage
QQ_AVAILABLE = True
except ImportError:
QQ_AVAILABLE = False
botpy = None
C2CMessage = None
GroupMessage = None
@dataclass
class QQConfig(BaseChannelConfig):
app_id: str = ""
app_secret: str = ""
text_chunk_limit: int = 4096
def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
"""Create a botpy Client subclass bound to the given channel."""
intents = botpy.Intents(public_messages=True, direct_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_msg(message, "c2c")
async def on_group_at_message_create(self, message: "GroupMessage"):
await channel._on_msg(message, "group")
return _Bot
class QQChannel(Channel):
"""QQ channel using botpy SDK."""
name = "qq"
capabilities = QQ_CAPS
_ready_attrs = ("_client", "_running")
_non_retryable_patterns = ()
_mention_pattern = r"@\S+\s*"
_mention_strip_count = 1
def __init__(self, config: QQConfig):
super().__init__(config)
self._client: "botpy.Client | None" = None
self._bot_task: asyncio.Task | None = None
self._processed_ids: deque = deque(maxlen=1000)
self._msg_seq: dict[str, int] = {} # msg_id -> next seq number
self._msg_seq_order: deque = deque(maxlen=500)
self._msg_seq_ids: set[str] = set() # companion set for O(1) lookup
# ── Lifecycle ─────────────────────────────────────────────────
async def start(self) -> None:
if not QQ_AVAILABLE:
raise ChannelError("QQ SDK not installed. Run: pip install qq-botpy")
if not self.config.app_id or not self.config.app_secret:
raise ChannelError("QQ app_id and app_secret are required")
self._running = True
BotClass = _make_bot_class(self)
self._client = BotClass()
self._bot_task = asyncio.create_task(self._run_bot())
logger.info("QQ channel starting...")
async def _run_bot(self) -> None:
try:
await self._client.start(
appid=self.config.app_id, secret=self.config.app_secret
)
except Exception as e:
logger.error(f"QQ auth failed: {e}")
self._running = False
async def _cleanup(self) -> None:
self._running = False
if self._bot_task:
self._bot_task.cancel()
try:
await self._bot_task
except asyncio.CancelledError:
pass
self._client = None
logger.info("QQ channel stopped")
# ── Incoming ──────────────────────────────────────────────────
async def _on_msg(self, message, msg_type: str) -> None:
try:
if message.id in self._processed_ids:
return
self._processed_ids.append(message.id)
author = message.author
content = (message.content or "").strip()
if msg_type == "c2c":
sender_id = str(getattr(author, "user_openid", ""))
chat_id = sender_id
else:
sender_id = str(getattr(author, "member_openid", ""))
chat_id = str(getattr(message, "group_openid", ""))
# Handle attachments (images, files, audio, video)
annotations: list[str] = []
media_paths: list[str] = []
attachments = getattr(message, "attachments", None) or []
for att in attachments:
url = getattr(att, "url", "") or ""
filename = getattr(att, "filename", "attachment") or "attachment"
content_type = getattr(att, "content_type", "") or ""
if url:
local, ann = await self._download_attachment(
url,
f"qq_{filename}",
)
if local:
media_paths.append(local)
if ann:
annotations.append(ann)
else:
annotations.append(f"[{content_type or 'attachment'}: {filename}]")
if not content and not media_paths and not annotations:
return
await self._enqueue_raw(
RawIncoming(
sender_id=sender_id,
chat_id=chat_id,
text=content,
media_files=media_paths,
content_annotations=annotations,
timestamp=datetime.now(),
message_id=message.id,
is_group=(msg_type == "group"),
was_mentioned=True,
metadata={
"chat_id": chat_id,
"msg_type": msg_type,
"event_id": message.id,
"backend": "qq",
},
)
)
except Exception as e:
logger.error(f"Error handling QQ message: {e}")
# ── Send ──────────────────────────────────────────────────────
def _next_msg_seq(self, msg_id: str) -> int:
"""Return the next msg_seq for *msg_id* and increment the counter."""
seq = self._msg_seq.get(msg_id, 1)
self._msg_seq[msg_id] = seq + 1
if msg_id not in self._msg_seq_ids:
self._msg_seq_order.append(msg_id)
self._msg_seq_ids.add(msg_id)
if len(self._msg_seq_order) > 500:
oldest = self._msg_seq_order.popleft()
self._msg_seq_ids.discard(oldest)
self._msg_seq.pop(oldest, None)
return seq
async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
if not self._client:
raise ChannelError("QQ client not initialized")
msg_type = (metadata or {}).get("msg_type", "c2c")
msg_id = (metadata or {}).get("event_id", "")
seq = self._next_msg_seq(msg_id)
if msg_type == "group":
await self._client.api.post_group_message(
group_openid=chat_id,
msg_type=0,
content=raw_text,
msg_id=msg_id,
msg_seq=seq,
)
else:
await self._client.api.post_c2c_message(
openid=chat_id,
msg_type=0,
content=raw_text,
msg_id=msg_id,
msg_seq=seq,
)
# _send_typing_action: inherited no-op (QQ Bot API has no typing indicator)
# ── Media send ────────────────────────────────────────────────
# qq-botpy file_type constants: 1=image, 2=video, 3=audio
_FILE_TYPE_MAP = {
".jpg": 1,
".jpeg": 1,
".png": 1,
".gif": 1,
".webp": 1,
".bmp": 1,
".mp4": 2,
".mov": 2,
".avi": 2,
".mp3": 3,
".ogg": 3,
".m4a": 3,
".wav": 3,
".silk": 3,
}
async def _send_media_impl(
self,
recipient: str,
file_path: str,
caption: str = "",
metadata: dict | None = None,
) -> bool:
"""Send a media file through QQ Bot API.
Uses post_group_file / post_c2c_file with a URL. Local files
without a public URL are not supported — falls back to a text hint.
"""
if not self._client:
raise ChannelError("QQ client not initialized")
from pathlib import Path
chat_id = self._resolve_media_chat_id(recipient, metadata)
msg_type = (metadata or {}).get("msg_type", "c2c")
ext = Path(file_path).suffix.lower()
file_type = self._FILE_TYPE_MAP.get(ext, 1) # default to image
# qq-botpy file API requires a URL, not a local path
is_url = file_path.startswith("http://") or file_path.startswith("https://")
if not is_url:
# Fallback: send text hint for local files
name = Path(file_path).name
hint = f"[文件] {name}" + (f"\n{caption}" if caption else "")
await self._send_chunk(chat_id, hint, hint, None, metadata or {})
return True
try:
if msg_type == "group":
await self._client.api.post_group_file(
group_openid=chat_id,
file_type=file_type,
url=file_path,
srv_send_msg=True,
)
else:
await self._client.api.post_c2c_file(
openid=chat_id,
file_type=file_type,
url=file_path,
srv_send_msg=True,
)
except Exception as e:
logger.warning(f"QQ media send failed: {e}")
return False
if caption:
await self._send_chunk(chat_id, caption, caption, None, metadata or {})
return True