Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 108 additions & 11 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<account_id>' (DM), 'yuanbao:group:<group_code>' (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:<account_id>' (DM), 'yuanbao:group:<group_code>' (group chat), 'qqbot:c2c:<openid>' (private chat), 'qqbot:group:<group_openid>' (group chat)"
},
"message": {
"type": "string",
Expand Down Expand Up @@ -588,19 +588,35 @@ 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"
)
}
warning = 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
Expand Down Expand Up @@ -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
Expand All @@ -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:<openid>, group:<group_openid>, guild:<channel_id>
# 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",
Expand All @@ -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):
Expand Down