diff --git a/gateway/platforms/qqbot/__init__.py b/gateway/platforms/qqbot/__init__.py index d755ec48df09..9bf2142dbd38 100644 --- a/gateway/platforms/qqbot/__init__.py +++ b/gateway/platforms/qqbot/__init__.py @@ -19,6 +19,9 @@ QQAdapter, QQCloseError, check_qq_requirements, + get_active_adapter, + set_active_adapter, + send_qqbot_direct, _coerce_list, _ssrf_redirect_guard, ) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 086f5e073f5f..bb4e14d53130 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -3069,3 +3069,79 @@ def _is_duplicate(self, msg_id: str) -> bool: return True self._seen_messages[msg_id] = now return False + + +# -- Active instance registry (class-level singleton) -- + +_active_instance: Optional["QQAdapter"] = None + + +def get_active_adapter() -> Optional["QQAdapter"]: + """Return the currently connected QQAdapter, or None.""" + global _active_instance + return _active_instance + + +def set_active_adapter(adapter: Optional["QQAdapter"]) -> None: + """Register (or clear) the active adapter instance.""" + global _active_instance + _active_instance = adapter + + +async def send_qqbot_direct( + adapter: "QQAdapter", + chat_id: str, + message: str, + media_files: Optional[List[Tuple[str, bool]]] = None, +) -> dict: + """Send a message + optional media attachments via the running QQAdapter. + + Uses the adapter's WebSocket-connected REST API for text, and its + ``send_image_file`` / ``send_document`` methods for media. + + Returns a dict with ``success`` (bool) and optionally ``error`` (str). + """ + from pathlib import Path + + if not media_files: + # Plain text — use the adapter's send() + result = await adapter.send(chat_id, message) + return { + "success": result.success if result else False, + "error": result.error if result else None, + } + + # Send text first, then media attachments + if message.strip(): + await adapter.send(chat_id, message) + + last_error = None + _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'} + _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} + + for media_path, is_voice in media_files: + ext = Path(media_path).suffix.lower() + try: + if ext in _IMAGE_EXTS and not is_voice: + await adapter.send_image_file( + chat_id=chat_id, image_path=media_path, + ) + elif ext in _VIDEO_EXTS: + await adapter.send_video( + chat_id=chat_id, video_path=media_path, + ) + elif is_voice: + await adapter.send_voice( + chat_id=chat_id, audio_path=media_path, + ) + else: + await adapter.send_document( + chat_id=chat_id, file_path=media_path, + ) + except Exception as e: + last_error = str(e) + logger.warning("[QQBot] Media send failed for %s: %s", media_path, e) + + if last_error: + return {"success": False, "error": last_error} + return {"success": True} diff --git a/gateway/run.py b/gateway/run.py index 0f56ad61c391..2023c8e014c1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6076,11 +6076,13 @@ def _create_adapter( return BlueBubblesAdapter(config) elif platform == Platform.QQBOT: - from gateway.platforms.qqbot import QQAdapter, check_qq_requirements + from gateway.platforms.qqbot import QQAdapter, check_qq_requirements, set_active_adapter if not check_qq_requirements(): logger.warning("QQBot: aiohttp/httpx missing or QQ_APP_ID/QQ_CLIENT_SECRET not configured") return None - return QQAdapter(config) + adapter = QQAdapter(config) + set_active_adapter(adapter) + return adapter elif platform == Platform.YUANBAO: from gateway.platforms.yuanbao import YuanbaoAdapter, WEBSOCKETS_AVAILABLE diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 284eaab56a10..fe6c5044acf1 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -724,11 +724,26 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- QQ Bot: native media attachment via running gateway adapter --- + if platform == Platform.QQBOT and media_files: + last_result = None + for i, chunk in enumerate(chunks): + is_last = (i == len(chunks) - 1) + result = await _send_qqbot_media( + chat_id, + chunk, + media_files=media_files if is_last else None, + ) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + return last_result + # --- Non-media platforms --- if media_files and not message.strip(): return { "error": ( - f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu; " + f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao, feishu and qqbot; " f"target {platform.value} had only media attachments" ) } @@ -736,7 +751,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files: warning = ( f"MEDIA attachments were omitted for {platform.value}; " - "native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu" + "native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao, feishu and qqbot" ) last_result = None @@ -1981,6 +1996,159 @@ async def _send_qqbot(pconfig, chat_id, message): return _error(f"QQBot send failed: {e}") +async def _send_qqbot_media(chat_id, message, media_files=None): + """Send via QQBot with media attachments using REST API directly. + + Uses the same REST API approach as _send_qqbot, but also uploads + media files via chunked upload and sends RichMedia messages. + """ + try: + import httpx + except ImportError: + return _error("QQBot direct send requires httpx. Run: pip install httpx") + + from gateway.config import load_gateway_config, Platform + from dotenv import load_dotenv + + load_dotenv(os.path.expanduser('~/.hermes/.env')) + + cfg = load_gateway_config() + pconfig = cfg.platforms.get(Platform.QQBOT) + if not pconfig: + return _error("QQBot not configured. Set QQ_APP_ID and QQ_CLIENT_SECRET.") + + extra = pconfig.extra or {} + appid = extra.get("app_id") or os.getenv("QQ_APP_ID", "") + secret = extra.get("client_secret") or os.getenv("QQ_CLIENT_SECRET", "") + if not appid or not secret: + return _error("QQBot: QQ_APP_ID / QQ_CLIENT_SECRET not configured.") + + try: + async with httpx.AsyncClient(timeout=30) as client: + # Step 1: Get access token + token_resp = await client.post( + "https://bots.qq.com/app/getAppAccessToken", + json={"appId": str(appid), "clientSecret": str(secret)}, + ) + if token_resp.status_code != 200: + return _error(f"QQBot token request failed: {token_resp.status_code}") + token_data = token_resp.json() + access_token = token_data.get("access_token") + if not access_token: + return _error("QQBot: no access_token in response") + + headers = { + "Authorization": f"QQBot {access_token}", + "Content-Type": "application/json", + } + + from pathlib import Path + + # Determine chat type from chat_id format + chat_type = "c2c" + endpoint_prefix = f"/v2/users/{chat_id}" + + # Send text first (if any) + if message.strip(): + text_payload = {"content": message[:4000], "msg_type": 0} + for url_key, url_tmpl in [ + ("c2c", f"/v2/users/{chat_id}/messages"), + ("group", f"/v2/groups/{chat_id}/messages"), + ]: + resp = await client.post( + f"https://api.sgroup.qq.com{url_tmpl}", + json=text_payload, headers=headers, + ) + if resp.status_code in {200, 201}: + break + + # Send media attachments + if media_files: + from gateway.platforms.qqbot.chunked_upload import ChunkedUploader + + # Determine chat type from first media send + _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} + _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'} + + for media_path, is_voice in media_files: + ext = Path(media_path).suffix.lower() + local_path = Path(media_path).expanduser() + if not local_path.exists(): + logger.warning("QQBot: media file not found: %s", media_path) + continue + + # Determine file_type + if ext in _IMAGE_EXTS and not is_voice: + file_type = 1 # MEDIA_TYPE_IMAGE + elif ext in _VIDEO_EXTS: + file_type = 2 # MEDIA_TYPE_VIDEO + elif is_voice: + file_type = 3 # MEDIA_TYPE_VOICE + else: + file_type = 4 # MEDIA_TYPE_FILE + + # Upload via chunked upload + async def _api_req(method, path, **kw): + body = kw.get("body", kw.get("json", {})) + resp = await client.post( + f"https://api.sgroup.qq.com{path}", + json=body, headers=headers, + ) + return resp.json() + + uploader = ChunkedUploader( + api_request=_api_req, + http_put=client.put, + log_tag="QQBot", + ) + + complete = await uploader.upload( + chat_type=chat_type, + target_id=chat_id, + file_path=str(local_path), + file_type=file_type, + file_name=local_path.name, + ) + + file_info = complete.get("file_info") or ( + complete.get("data", {}) or {} + ).get("file_info") + if not file_info: + logger.warning("QQBot: upload returned no file_info: %s", complete) + continue + + # Send media message + media_body = { + "msg_type": 7, # MSG_TYPE_MEDIA + "media": {"file_info": file_info}, + } + for url_key, url_tmpl in [ + ("c2c", f"/v2/users/{chat_id}/messages"), + ("group", f"/v2/groups/{chat_id}/messages"), + ]: + resp = await client.post( + f"https://api.sgroup.qq.com{url_tmpl}", + json=media_body, headers=headers, + ) + if resp.status_code in {200, 201}: + break + + return {"success": True, "platform": "qqbot", "chat_id": chat_id} + except Exception as e: + return _error(f"QQBot media send failed: {e}") + + +async def _qq_api_call(client, headers, method, path, body): + """Helper to call QQ Bot REST API.""" + if method.upper() == "POST": + resp = await client.post( + f"https://api.sgroup.qq.com{path}", + json=body, headers=headers, + ) + return resp.json() + return {} + + async def _send_yuanbao(chat_id, message, media_files=None): """Send via Yuanbao using the running gateway adapter's WebSocket connection.