返回 JoyAI-Echo
1 """QQ channel implementation using botpy SDK.
2
3 Inbound:
4 - Parse QQ botpy messages (C2C / Group)
5 - Download attachments to media dir using chunked streaming write (memory-safe)
6 - Publish to Nanobot bus via BaseChannel._handle_message()
7 - Content includes a clear, actionable "Received files:" list with local paths
8
9 Outbound:
10 - Send attachments (msg.media) first via QQ rich media API (base64 upload + msg_type=7)
11 - Then send text (plain or markdown)
12 - msg.media supports local paths, file:// paths, and http(s) URLs
13
14 Notes:
15 - QQ restricts many audio/video formats. We conservatively classify as image vs file.
16 - Attachment structures differ across botpy versions; we try multiple field candidates.
17 """
18
19 from __future__ import annotations
20
21 import asyncio
22 import base64
23 import mimetypes
24 import os
25 import re
26 import time
27 from collections import deque
28 from pathlib import Path
29 from typing import TYPE_CHECKING, Any, Literal
30 from urllib.parse import unquote, urlparse
31
32 import aiohttp
33 from loguru import logger
34 from pydantic import Field
35
36 from nanobot.bus.events import OutboundMessage
37 from nanobot.bus.queue import MessageBus
38 from nanobot.channels.base import BaseChannel
39 from nanobot.config.schema import Base
40 from nanobot.security.network import validate_url_target
41
42 try:
43 from nanobot.config.paths import get_media_dir
44 except Exception: # pragma: no cover
45 get_media_dir = None # type: ignore
46
47 try:
48 import botpy
49 from botpy.http import Route
50
51 QQ_AVAILABLE = True
52 except ImportError: # pragma: no cover
53 QQ_AVAILABLE = False
54 botpy = None
55 Route = None
56
57 if TYPE_CHECKING:
58 from botpy.message import BaseMessage, C2CMessage, GroupMessage
59 from botpy.types.message import Media
60
61
62 # QQ rich media file_type: 1=image, 4=file
63 # (2=voice, 3=video are restricted; we only use image vs file)
64 QQ_FILE_TYPE_IMAGE = 1
65 QQ_FILE_TYPE_FILE = 4
66
67 _IMAGE_EXTS = {
68 ".png",
69 ".jpg",
70 ".jpeg",
71 ".gif",
72 ".bmp",
73 ".webp",
74 ".tif",
75 ".tiff",
76 ".ico",
77 ".svg",
78 }
79
80 # Replace unsafe characters with "_", keep Chinese and common safe punctuation.
81 _SAFE_NAME_RE = re.compile(r"[^\w.\-()\[\]()【】\u4e00-\u9fff]+", re.UNICODE)
82
83
84 def _sanitize_filename(name: str) -> str:
85 """Sanitize filename to avoid traversal and problematic chars."""
86 name = (name or "").strip()
87 name = Path(name).name
88 name = _SAFE_NAME_RE.sub("_", name).strip("._ ")
89 return name
90
91
92 def _is_image_name(name: str) -> bool:
93 return Path(name).suffix.lower() in _IMAGE_EXTS
94
95
96 def _guess_send_file_type(filename: str) -> int:
97 """Conservative send type: images -> 1, else -> 4."""
98 ext = Path(filename).suffix.lower()
99 mime, _ = mimetypes.guess_type(filename)
100 if ext in _IMAGE_EXTS or (mime and mime.startswith("image/")):
101 return QQ_FILE_TYPE_IMAGE
102 return QQ_FILE_TYPE_FILE
103
104
105 def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
106 """Create a botpy Client subclass bound to the given channel."""
107 intents = botpy.Intents(public_messages=True, direct_message=True)
108
109 class _Bot(botpy.Client):
110 def __init__(self):
111 # Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
112 super().__init__(intents=intents, ext_handlers=False)
113
114 async def on_ready(self):
115 logger.info("QQ bot ready: {}", self.robot.name)
116
117 async def on_c2c_message_create(self, message: C2CMessage):
118 await channel._on_message(message, is_group=False)
119
120 async def on_group_at_message_create(self, message: GroupMessage):
121 await channel._on_message(message, is_group=True)
122
123 async def on_direct_message_create(self, message):
124 await channel._on_message(message, is_group=False)
125
126 return _Bot
127
128
129 class QQConfig(Base):
130 """QQ channel configuration using botpy SDK."""
131
132 enabled: bool = False
133 app_id: str = ""
134 secret: str = ""
135 allow_from: list[str] = Field(default_factory=list)
136 msg_format: Literal["plain", "markdown"] = "plain"
137 ack_message: str = "⏳ Processing..."
138
139 # Optional: directory to save inbound attachments. If empty, use nanobot get_media_dir("qq").
140 media_dir: str = ""
141
142 # Download tuning
143 download_chunk_size: int = 1024 * 256 # 256KB
144 download_max_bytes: int = 1024 * 1024 * 200 # 200MB safety limit
145
146
147 class QQChannel(BaseChannel):
148 """QQ channel using botpy SDK with WebSocket connection."""
149
150 name = "qq"
151 display_name = "QQ"
152
153 @classmethod
154 def default_config(cls) -> dict[str, Any]:
155 return QQConfig().model_dump(by_alias=True)
156
157 def __init__(self, config: Any, bus: MessageBus):
158 if isinstance(config, dict):
159 config = QQConfig.model_validate(config)
160 super().__init__(config, bus)
161 self.config: QQConfig = config
162
163 self._client: botpy.Client | None = None
164 self._http: aiohttp.ClientSession | None = None
165
166 self._processed_ids: deque[str] = deque(maxlen=1000)
167 self._msg_seq: int = 1 # used to avoid QQ API dedup
168 self._chat_type_cache: dict[str, str] = {}
169
170 self._media_root: Path = self._init_media_root()
171
172 # ---------------------------
173 # Lifecycle
174 # ---------------------------
175
176 def _init_media_root(self) -> Path:
177 """Choose a directory for saving inbound attachments."""
178 if self.config.media_dir:
179 root = Path(self.config.media_dir).expanduser()
180 elif get_media_dir:
181 try:
182 root = Path(get_media_dir("qq"))
183 except Exception:
184 root = Path.home() / ".nanobot" / "media" / "qq"
185 else:
186 root = Path.home() / ".nanobot" / "media" / "qq"
187
188 root.mkdir(parents=True, exist_ok=True)
189 logger.info("QQ media directory: {}", str(root))
190 return root
191
192 async def start(self) -> None:
193 """Start the QQ bot with auto-reconnect loop."""
194 if not QQ_AVAILABLE:
195 logger.error("QQ SDK not installed. Run: pip install qq-botpy")
196 return
197
198 if not self.config.app_id or not self.config.secret:
199 logger.error("QQ app_id and secret not configured")
200 return
201
202 self._running = True
203 self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
204
205 self._client = _make_bot_class(self)()
206 logger.info("QQ bot started (C2C & Group supported)")
207 await self._run_bot()
208
209 async def _run_bot(self) -> None:
210 """Run the bot connection with auto-reconnect."""
211 while self._running:
212 try:
213 await self._client.start(appid=self.config.app_id, secret=self.config.secret)
214 except Exception as e:
215 logger.warning("QQ bot error: {}", e)
216 if self._running:
217 logger.info("Reconnecting QQ bot in 5 seconds...")
218 await asyncio.sleep(5)
219
220 async def stop(self) -> None:
221 """Stop bot and cleanup resources."""
222 self._running = False
223 if self._client:
224 try:
225 await self._client.close()
226 except Exception:
227 pass
228 self._client = None
229
230 if self._http:
231 try:
232 await self._http.close()
233 except Exception:
234 pass
235 self._http = None
236
237 logger.info("QQ bot stopped")
238
239 # ---------------------------
240 # Outbound (send)
241 # ---------------------------
242
243 async def send(self, msg: OutboundMessage) -> None:
244 """Send attachments first, then text."""
245 try:
246 if not self._client:
247 logger.warning("QQ client not initialized")
248 return
249
250 msg_id = msg.metadata.get("message_id")
251 chat_type = self._chat_type_cache.get(msg.chat_id, "c2c")
252 is_group = chat_type == "group"
253
254 # 1) Send media
255 for media_ref in msg.media or []:
256 ok = await self._send_media(
257 chat_id=msg.chat_id,
258 media_ref=media_ref,
259 msg_id=msg_id,
260 is_group=is_group,
261 )
262 if not ok:
263 filename = (
264 os.path.basename(urlparse(media_ref).path)
265 or os.path.basename(media_ref)
266 or "file"
267 )
268 await self._send_text_only(
269 chat_id=msg.chat_id,
270 is_group=is_group,
271 msg_id=msg_id,
272 content=f"[Attachment send failed: {filename}]",
273 )
274
275 # 2) Send text
276 if msg.content and msg.content.strip():
277 await self._send_text_only(
278 chat_id=msg.chat_id,
279 is_group=is_group,
280 msg_id=msg_id,
281 content=msg.content.strip(),
282 )
283 except (aiohttp.ClientError, OSError):
284 # Network / transport errors — propagate so ChannelManager can retry
285 raise
286 except Exception:
287 logger.exception("Error sending QQ message to chat_id={}", msg.chat_id)
288
289 async def _send_text_only(
290 self,
291 chat_id: str,
292 is_group: bool,
293 msg_id: str | None,
294 content: str,
295 ) -> None:
296 """Send a plain/markdown text message."""
297 if not self._client:
298 return
299
300 self._msg_seq += 1
301 use_markdown = self.config.msg_format == "markdown"
302 payload: dict[str, Any] = {
303 "msg_type": 2 if use_markdown else 0,
304 "msg_id": msg_id,
305 "msg_seq": self._msg_seq,
306 }
307 if use_markdown:
308 payload["markdown"] = {"content": content}
309 else:
310 payload["content"] = content
311
312 if is_group:
313 await self._client.api.post_group_message(group_openid=chat_id, **payload)
314 else:
315 await self._client.api.post_c2c_message(openid=chat_id, **payload)
316
317 async def _send_media(
318 self,
319 chat_id: str,
320 media_ref: str,
321 msg_id: str | None,
322 is_group: bool,
323 ) -> bool:
324 """Read bytes -> base64 upload -> msg_type=7 send."""
325 if not self._client:
326 return False
327
328 data, filename = await self._read_media_bytes(media_ref)
329 if not data or not filename:
330 return False
331
332 try:
333 file_type = _guess_send_file_type(filename)
334 file_data_b64 = base64.b64encode(data).decode()
335
336 media_obj = await self._post_base64file(
337 chat_id=chat_id,
338 is_group=is_group,
339 file_type=file_type,
340 file_data=file_data_b64,
341 file_name=filename,
342 srv_send_msg=False,
343 )
344 if not media_obj:
345 logger.error("QQ media upload failed: empty response")
346 return False
347
348 self._msg_seq += 1
349 if is_group:
350 await self._client.api.post_group_message(
351 group_openid=chat_id,
352 msg_type=7,
353 msg_id=msg_id,
354 msg_seq=self._msg_seq,
355 media=media_obj,
356 )
357 else:
358 await self._client.api.post_c2c_message(
359 openid=chat_id,
360 msg_type=7,
361 msg_id=msg_id,
362 msg_seq=self._msg_seq,
363 media=media_obj,
364 )
365
366 logger.info("QQ media sent: {}", filename)
367 return True
368 except (aiohttp.ClientError, OSError) as e:
369 # Network / transport errors — propagate for retry by caller
370 logger.warning("QQ send media network error filename={} err={}", filename, e)
371 raise
372 except Exception as e:
373 # API-level or other non-network errors — return False so send() can fallback
374 logger.error("QQ send media failed filename={} err={}", filename, e)
375 return False
376
377 async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]:
378 """Read bytes from http(s) or local file path; return (data, filename)."""
379 media_ref = (media_ref or "").strip()
380 if not media_ref:
381 return None, None
382
383 # Local file: plain path or file:// URI
384 if not media_ref.startswith("http://") and not media_ref.startswith("https://"):
385 try:
386 if media_ref.startswith("file://"):
387 parsed = urlparse(media_ref)
388 # Windows: path in netloc; Unix: path in path
389 raw = parsed.path or parsed.netloc
390 local_path = Path(unquote(raw))
391 else:
392 local_path = Path(os.path.expanduser(media_ref))
393
394 if not local_path.is_file():
395 logger.warning("QQ outbound media file not found: {}", str(local_path))
396 return None, None
397
398 data = await asyncio.to_thread(local_path.read_bytes)
399 return data, local_path.name
400 except Exception as e:
401 logger.warning("QQ outbound media read error ref={} err={}", media_ref, e)
402 return None, None
403
404 # Remote URL
405 ok, err = validate_url_target(media_ref)
406 if not ok:
407 logger.warning("QQ outbound media URL validation failed url={} err={}", media_ref, err)
408 return None, None
409
410 if not self._http:
411 self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
412 try:
413 async with self._http.get(media_ref, allow_redirects=True) as resp:
414 if resp.status >= 400:
415 logger.warning(
416 "QQ outbound media download failed status={} url={}",
417 resp.status,
418 media_ref,
419 )
420 return None, None
421 data = await resp.read()
422 if not data:
423 return None, None
424 filename = os.path.basename(urlparse(media_ref).path) or "file.bin"
425 return data, filename
426 except Exception as e:
427 logger.warning("QQ outbound media download error url={} err={}", media_ref, e)
428 return None, None
429
430 # https://github.com/tencent-connect/botpy/issues/198
431 # https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/send-receive/rich-media.html
432 async def _post_base64file(
433 self,
434 chat_id: str,
435 is_group: bool,
436 file_type: int,
437 file_data: str,
438 file_name: str | None = None,
439 srv_send_msg: bool = False,
440 ) -> Media:
441 """Upload base64-encoded file and return Media object."""
442 if not self._client:
443 raise RuntimeError("QQ client not initialized")
444
445 if is_group:
446 endpoint = "/v2/groups/{group_openid}/files"
447 id_key = "group_openid"
448 else:
449 endpoint = "/v2/users/{openid}/files"
450 id_key = "openid"
451
452 payload: dict[str, Any] = {
453 id_key: chat_id,
454 "file_type": file_type,
455 "file_data": file_data,
456 "srv_send_msg": srv_send_msg,
457 }
458 # Only pass file_name for non-image types (file_type=4).
459 # Passing file_name for images causes QQ client to render them as
460 # file attachments instead of inline images.
461 if file_type != QQ_FILE_TYPE_IMAGE and file_name:
462 payload["file_name"] = file_name
463
464 route = Route("POST", endpoint, **{id_key: chat_id})
465 result = await self._client.api._http.request(route, json=payload)
466
467 # Extract only the file_info field to avoid extra fields (file_uuid, ttl, etc.)
468 # that may confuse QQ client when sending the media object.
469 if isinstance(result, dict) and "file_info" in result:
470 return {"file_info": result["file_info"]}
471 return result
472
473 # ---------------------------
474 # Inbound (receive)
475 # ---------------------------
476
477 async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None:
478 """Parse inbound message, download attachments, and publish to the bus."""
479 try:
480 if data.id in self._processed_ids:
481 return
482 self._processed_ids.append(data.id)
483
484 if is_group:
485 chat_id = data.group_openid
486 user_id = data.author.member_openid
487 self._chat_type_cache[chat_id] = "group"
488 else:
489 chat_id = str(
490 getattr(data.author, "id", None)
491 or getattr(data.author, "user_openid", "unknown")
492 )
493 user_id = chat_id
494 self._chat_type_cache[chat_id] = "c2c"
495
496 content = (data.content or "").strip()
497
498 # the data used by tests don't contain attachments property
499 # so we use getattr with a default of [] to avoid AttributeError in tests
500 attachments = getattr(data, "attachments", None) or []
501 media_paths, recv_lines, att_meta = await self._handle_attachments(attachments)
502
503 # Compose content that always contains actionable saved paths
504 if recv_lines:
505 tag = (
506 "[Image]"
507 if any(_is_image_name(Path(p).name) for p in media_paths)
508 else "[File]"
509 )
510 file_block = "Received files:\n" + "\n".join(recv_lines)
511 content = (
512 f"{content}\n\n{file_block}".strip() if content else f"{tag}\n{file_block}"
513 )
514
515 if not content and not media_paths:
516 return
517
518 if self.config.ack_message:
519 try:
520 await self._send_text_only(
521 chat_id=chat_id,
522 is_group=is_group,
523 msg_id=data.id,
524 content=self.config.ack_message,
525 )
526 except Exception:
527 logger.debug("QQ ack message failed for chat_id={}", chat_id)
528
529 await self._handle_message(
530 sender_id=user_id,
531 chat_id=chat_id,
532 content=content,
533 media=media_paths if media_paths else None,
534 metadata={
535 "message_id": data.id,
536 "attachments": att_meta,
537 },
538 )
539 except Exception:
540 logger.exception("Error handling QQ inbound message id={}", getattr(data, "id", "?"))
541
542 async def _handle_attachments(
543 self,
544 attachments: list[BaseMessage._Attachments],
545 ) -> tuple[list[str], list[str], list[dict[str, Any]]]:
546 """Extract, download (chunked), and format attachments for agent consumption."""
547 media_paths: list[str] = []
548 recv_lines: list[str] = []
549 att_meta: list[dict[str, Any]] = []
550
551 if not attachments:
552 return media_paths, recv_lines, att_meta
553
554 for att in attachments:
555 url = getattr(att, "url", None) or ""
556 filename = getattr(att, "filename", None) or ""
557 ctype = getattr(att, "content_type", None) or ""
558
559 logger.info("Downloading file from QQ: {}", filename or url)
560 local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename)
561
562 att_meta.append(
563 {
564 "url": url,
565 "filename": filename,
566 "content_type": ctype,
567 "saved_path": local_path,
568 }
569 )
570
571 if local_path:
572 media_paths.append(local_path)
573 shown_name = filename or os.path.basename(local_path)
574 recv_lines.append(f"- {shown_name}\n saved: {local_path}")
575 else:
576 shown_name = filename or url
577 recv_lines.append(f"- {shown_name}\n saved: [download failed]")
578
579 return media_paths, recv_lines, att_meta
580
581 async def _download_to_media_dir_chunked(
582 self,
583 url: str,
584 filename_hint: str = "",
585 ) -> str | None:
586 """Download an inbound attachment using streaming chunk write.
587
588 Uses chunked streaming to avoid loading large files into memory.
589 Enforces a max download size and writes to a .part temp file
590 that is atomically renamed on success.
591 """
592 # Handle protocol-relative URLs (e.g. "//multimedia.nt.qq.com/...")
593 if url.startswith("//"):
594 url = f"https:{url}"
595
596 if not self._http:
597 self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
598
599 safe = _sanitize_filename(filename_hint)
600 ts = int(time.time() * 1000)
601 tmp_path: Path | None = None
602
603 try:
604 async with self._http.get(
605 url,
606 timeout=aiohttp.ClientTimeout(total=120),
607 allow_redirects=True,
608 ) as resp:
609 if resp.status != 200:
610 logger.warning("QQ download failed: status={} url={}", resp.status, url)
611 return None
612
613 ctype = (resp.headers.get("Content-Type") or "").lower()
614
615 # Infer extension: url -> filename_hint -> content-type -> fallback
616 ext = Path(urlparse(url).path).suffix
617 if not ext:
618 ext = Path(filename_hint).suffix
619 if not ext:
620 if "png" in ctype:
621 ext = ".png"
622 elif "jpeg" in ctype or "jpg" in ctype:
623 ext = ".jpg"
624 elif "gif" in ctype:
625 ext = ".gif"
626 elif "webp" in ctype:
627 ext = ".webp"
628 elif "pdf" in ctype:
629 ext = ".pdf"
630 else:
631 ext = ".bin"
632
633 if safe:
634 if not Path(safe).suffix:
635 safe = safe + ext
636 filename = safe
637 else:
638 filename = f"qq_file_{ts}{ext}"
639
640 target = self._media_root / filename
641 if target.exists():
642 target = self._media_root / f"{target.stem}_{ts}{target.suffix}"
643
644 tmp_path = target.with_suffix(target.suffix + ".part")
645
646 # Stream write
647 downloaded = 0
648 chunk_size = max(1024, int(self.config.download_chunk_size or 262144))
649 max_bytes = max(
650 1024 * 1024, int(self.config.download_max_bytes or (200 * 1024 * 1024))
651 )
652
653 def _open_tmp():
654 tmp_path.parent.mkdir(parents=True, exist_ok=True)
655 return open(tmp_path, "wb") # noqa: SIM115
656
657 f = await asyncio.to_thread(_open_tmp)
658 try:
659 async for chunk in resp.content.iter_chunked(chunk_size):
660 if not chunk:
661 continue
662 downloaded += len(chunk)
663 if downloaded > max_bytes:
664 logger.warning(
665 "QQ download exceeded max_bytes={} url={} -> abort",
666 max_bytes,
667 url,
668 )
669 return None
670 await asyncio.to_thread(f.write, chunk)
671 finally:
672 await asyncio.to_thread(f.close)
673
674 # Atomic rename
675 await asyncio.to_thread(os.replace, tmp_path, target)
676 tmp_path = None # mark as moved
677 logger.info("QQ file saved: {}", str(target))
678 return str(target)
679
680 except Exception as e:
681 logger.error("QQ download error: {}", e)
682 return None
683 finally:
684 # Cleanup partial file
685 if tmp_path is not None:
686 try:
687 tmp_path.unlink(missing_ok=True)
688 except Exception:
689 pass
690
690 lines PYTHON