diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 62712e4581ff..fa158b2986a6 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -132,7 +132,7 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs) }, "target": { "type": "string", - "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'yuanbao:direct:' (DM), 'yuanbao:group:' (group chat)" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'yuanbao:direct:' (DM), 'yuanbao:group:' (group chat), 'qqbot:c2c:' (private chat), 'qqbot:group:' (group chat)" }, "message": { "type": "string", @@ -588,11 +588,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- QQBot: native media attachment support via REST API --- + 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( + pconfig, + 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 and yuanbao; " + f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and qqbot; " f"target {platform.value} had only media attachments" ) } @@ -600,7 +616,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 and yuanbao" + "native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and qqbot" ) last_result = None @@ -1648,12 +1664,11 @@ def _check_send_message(): return False -async def _send_qqbot(pconfig, chat_id, message): +async def _send_qqbot(pconfig, chat_id, message, media_files=None): """Send via QQBot using the REST API directly (no WebSocket needed). Uses the QQ Bot Open Platform REST endpoints to get an access token - and post a message. Works for guild channels without requiring - a running gateway adapter. + and post a message. Supports text, images, voice, video, and files. """ try: import httpx @@ -1667,8 +1682,23 @@ async def _send_qqbot(pconfig, chat_id, message): if not appid or not secret: return _error("QQBot: QQ_APP_ID / QQ_CLIENT_SECRET not configured.") + # Determine chat type from chat_id format or default to c2c + # Supported formats: c2c:, group:, guild: + # Or just plain openid (treated as c2c) + chat_type = "c2c" + target_id = chat_id + if chat_id.startswith("c2c:"): + chat_type = "c2c" + target_id = chat_id[4:] + elif chat_id.startswith("group:"): + chat_type = "group" + target_id = chat_id[6:] + elif chat_id.startswith("guild:"): + chat_type = "guild" + target_id = chat_id[6:] + try: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: # Step 1: Get access token token_resp = await client.post( "https://bots.qq.com/app/getAppAccessToken", @@ -1681,13 +1711,80 @@ async def _send_qqbot(pconfig, chat_id, message): if not access_token: return _error(f"QQBot: no access_token in response") - # Step 2: Send message via REST headers = { "Authorization": f"QQBot {access_token}", - "Content-Type": "application/json", } - url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages" - payload = {"content": message[:4000], "msg_type": 0} + + # Step 2: Upload media files if present + file_info_list = [] + if media_files: + for media_file in media_files: + # Determine file type: 1=image, 2=video, 3=voice, 4=file + file_type = 4 # default to file + file_ext = media_file.lower().split(".")[-1] if "." in media_file else "" + if file_ext in ("png", "jpg", "jpeg", "gif"): + file_type = 1 + elif file_ext in ("mp4", "mov", "avi"): + file_type = 2 + elif file_ext in ("mp3", "wav", "flac", "silk", "amr"): + file_type = 3 + + # Build upload URL based on chat type + if chat_type == "c2c": + upload_url = f"https://api.sgroup.qq.com/v2/users/{target_id}/files" + elif chat_type == "group": + upload_url = f"https://api.sgroup.qq.com/v2/groups/{target_id}/files" + else: + # Guild uses channel message API, media not supported the same way + return _error("QQBot: Media upload for guild channels not yet supported") + + # Read file data + try: + with open(media_file, "rb") as f: + file_data = f.read() + except Exception as e: + return _error(f"QQBot: Failed to read media file {media_file}: {e}") + + # Upload the file + upload_headers = { + **headers, + } + files_payload = { + "file": (media_file.split("/")[-1], file_data), + "file_type": (None, str(file_type)), + "srv_send_msg": (None, "false"), + } + + upload_resp = await client.post( + upload_url, + headers=upload_headers, + files=files_payload, + ) + if upload_resp.status_code not in (200, 201): + return _error(f"QQBot media upload failed: {upload_resp.status_code} {upload_resp.text}") + + upload_data = upload_resp.json() + file_info = upload_data.get("file_info") + if not file_info: + return _error(f"QQBot: no file_info in upload response") + file_info_list.append(file_info) + + # Step 3: Send message via REST + headers["Content-Type"] = "application/json" + + if chat_type == "c2c": + url = f"https://api.sgroup.qq.com/v2/users/{target_id}/messages" + elif chat_type == "group": + url = f"https://api.sgroup.qq.com/v2/groups/{target_id}/messages" + else: + # Guild channel + url = f"https://api.sgroup.qq.com/channels/{target_id}/messages" + + payload = {"content": message[:4000] if message else "", "msg_type": 0} + + # Add media if we have uploaded files + if file_info_list: + payload["media"] = {"file_info": file_info_list[0]} resp = await client.post(url, json=payload, headers=headers) if resp.status_code in (200, 201):