Files
hermes-agent/tests/tools/test_qqbot_send_message_media.py
Brooklyn Nicholson ebadb5462e fix(qqbot): degrade media send failure to text + warning instead of dropping it
Screen finding on #131927: when QQ media delivery failed (stale local
path, daily upload quota, oversized file, or any upload/API error),
_send_qqbot's media path returned a bare error and the caption/message
text was never delivered — a regression against main, where QQ
text+media degraded to the text delivered plus a 'MEDIA attachments
were omitted' warning.

- collect per-attachment failures as warnings and keep delivering
  (previously the first failed attachment aborted the whole send)
- when nothing was deliverable, fall back to sending the text or the
  caption standalone, matching the pre-media generic path
- surface collected warnings on the final result (and on error returns)

Tests: missing-file, upload-quota and generic-delivery failure flows
assert the text still lands exactly once with a warning (red on the
PR head before this fix).
2026-10-05 18:39:43 -05:00

369 lines
13 KiB
Python

"""QQ Bot proactive MEDIA delivery via send_message / _send_to_platform (#37315)."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from gateway.config import Platform
from gateway.platforms.qqbot.constants import (
MEDIA_TYPE_FILE,
MEDIA_TYPE_IMAGE,
MEDIA_TYPE_VIDEO,
MEDIA_TYPE_VOICE,
MSG_TYPE_MEDIA,
)
from tools.send_message_senders import _qqbot_media_file_type
from tools.send_message_tool import _send_qqbot, _send_to_platform
def _pconfig():
return SimpleNamespace(
token="client-secret",
extra={"app_id": "app-123", "client_secret": "client-secret"},
enabled=True,
)
class TestQqbotMediaFileType:
def test_image_ext(self):
assert _qqbot_media_file_type("/tmp/a.PNG", False) == MEDIA_TYPE_IMAGE
def test_video_ext(self):
assert _qqbot_media_file_type("/tmp/a.mp4", False) == MEDIA_TYPE_VIDEO
def test_voice_flag_or_ext(self):
assert _qqbot_media_file_type("/tmp/a.ogg", True) == MEDIA_TYPE_VOICE
assert _qqbot_media_file_type("/tmp/a.opus", False) == MEDIA_TYPE_VOICE
def test_generic_file(self):
assert _qqbot_media_file_type("/tmp/report.pdf", False) == MEDIA_TYPE_FILE
class TestSendToPlatformQqbotMediaRouting:
def test_media_only_routes_to_send_qqbot_not_allowlist_error(self, tmp_path):
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n")
media = [(str(img), False)]
with patch(
"tools.send_message_tool._send_qqbot",
new=AsyncMock(
return_value={
"success": True,
"platform": "qqbot",
"chat_id": "openid-1",
}
),
) as mock_send:
result = asyncio.run(
_send_to_platform(
Platform.QQBOT,
_pconfig(),
"openid-1",
"",
media_files=media,
)
)
assert result["success"] is True
assert "only supported for" not in str(result)
mock_send.assert_awaited_once()
assert mock_send.await_args.kwargs.get("media_files") == media
def test_text_plus_media_uses_caption_not_omission_warning(self, tmp_path):
img = tmp_path / "photo.jpg"
img.write_bytes(b"jpeg-bytes")
media = [(str(img), False)]
with patch(
"tools.send_message_tool._send_qqbot",
new=AsyncMock(
return_value={"success": True, "platform": "qqbot", "chat_id": "oid"}
),
) as mock_send:
result = asyncio.run(
_send_to_platform(
Platform.QQBOT,
_pconfig(),
"oid",
"hello caption",
media_files=media,
)
)
assert result.get("success") is True
assert not any("omitted" in w for w in result.get("warnings", []))
assert mock_send.await_count == 1
call = mock_send.await_args
assert call.kwargs.get("media_files") == media
assert call.kwargs.get("caption") == "hello caption"
assert call.args[2] == ""
def _token_client(post_handler):
"""httpx.AsyncClient fake whose ``post`` delegates to ``post_handler(url, json)``."""
class _Client:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def post(self, url, json=None, headers=None):
return post_handler(url, json)
class _HttpxMod:
AsyncClient = _Client
return _HttpxMod()
def _token_ok():
def _post(url, json):
assert "getAppAccessToken" in url
return SimpleNamespace(status_code=200, json=lambda: {"access_token": "at-1"})
return _post
class TestSendQqbotMedia:
def test_media_path_calls_deliver_after_token(self, tmp_path):
img = tmp_path / "logo.png"
img.write_bytes(b"png-data")
token_resp = SimpleNamespace(
status_code=200,
json=lambda: {"access_token": "at-1"},
)
class _Client:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def post(self, url, json=None, headers=None):
assert "getAppAccessToken" in url or url.endswith("getAppAccessToken")
return token_resp
class _HttpxMod:
AsyncClient = _Client
deliver = AsyncMock(
return_value={
"success": True,
"platform": "qqbot",
"chat_id": "user-openid",
"message_id": "m-1",
"chat_type": "c2c",
}
)
with patch.dict("sys.modules", {"httpx": _HttpxMod()}), patch(
"tools.send_message_senders._qqbot_deliver_one_media",
new=deliver,
):
result = asyncio.run(
_send_qqbot(
_pconfig(),
"user-openid",
"",
media_files=[(str(img), False)],
caption="see this",
)
)
assert result["success"] is True
deliver.assert_awaited_once()
_args, kwargs = deliver.await_args
assert kwargs.get("caption") == "see this" or (
len(_args) >= 6 and _args[5] == "see this"
)
# chat_id + path
assert "user-openid" in _args
assert str(img) in _args
def test_deliver_one_media_falls_back_c2c_to_group(self, tmp_path):
from tools.send_message_senders import _qqbot_deliver_one_media
img = tmp_path / "a.png"
img.write_bytes(b"x")
calls = []
async def _fake_upload_local(
client, headers, chat_type, chat_id, media_path, file_type
):
calls.append(("upload", chat_type, file_type))
if chat_type == "c2c":
raise RuntimeError("c2c rejected")
return {"file_info": "FI-99"}
async def _fake_send_media(
client, headers, chat_type, chat_id, file_info, caption=None
):
calls.append(("send", chat_type, file_info, caption))
return {"id": "mid-2"}
with patch(
"tools.send_message_senders._qqbot_upload_local_file",
new=AsyncMock(side_effect=_fake_upload_local),
), patch(
"tools.send_message_senders._qqbot_send_media_message",
new=AsyncMock(side_effect=_fake_send_media),
):
result = asyncio.run(
_qqbot_deliver_one_media(
client=None,
headers={"Authorization": "QQBot t"},
chat_id="gid",
media_path=str(img),
is_voice=False,
caption="cap",
)
)
assert result["success"] is True
assert result["chat_type"] == "group"
assert ("upload", "c2c", MEDIA_TYPE_IMAGE) in calls
assert ("upload", "group", MEDIA_TYPE_IMAGE) in calls
assert ("send", "group", "FI-99", "cap") in calls
class TestQqbotMediaFailureFallsBackToText:
"""Screen finding: media-delivery failure must not lose the caption/message text
(main degraded to text + omission warning; the media path returned a bare error)."""
def test_missing_media_file_still_delivers_caption_as_text(self, tmp_path):
missing = tmp_path / "gone.png" # never written: stale path
posted = []
def _post(url, json):
if "getAppAccessToken" in url:
return SimpleNamespace(status_code=200, json=lambda: {"access_token": "at-1"})
posted.append((url, json))
return SimpleNamespace(status_code=200, json=lambda: {"id": "text-mid"})
with patch.dict("sys.modules", {"httpx": _token_client(_post)}):
result = asyncio.run(
_send_qqbot(
_pconfig(),
"user-openid",
"",
media_files=[(str(missing), False)],
caption="the caption text",
)
)
assert result.get("success") is True, result
assert any(json and json.get("content") == "the caption text" for _, json in posted), posted
assert any("omitted" in w or "skipping" in w for w in result.get("warnings", [])), result
def test_upload_quota_error_still_delivers_caption_as_text(self, tmp_path):
img = tmp_path / "big.png"
img.write_bytes(b"png")
async def _deliver(client, headers, chat_id, media_path, is_voice, caption=None):
return {"error": "QQBot media send failed (guild/channel native media is unsupported; "
"tried c2c and group): c2c: QQ Bot API error [400] "
"/v2/users/user-openid/files: daily upload quota exceeded"}
posted = []
def _post(url, json):
if "getAppAccessToken" in url:
return SimpleNamespace(status_code=200, json=lambda: {"access_token": "at-1"})
posted.append((url, json))
return SimpleNamespace(status_code=200, json=lambda: {"id": "text-mid"})
with patch.dict("sys.modules", {"httpx": _token_client(_post)}), patch(
"tools.send_message_senders._qqbot_deliver_one_media", new=AsyncMock(side_effect=_deliver)
):
result = asyncio.run(
_send_qqbot(
_pconfig(),
"user-openid",
"",
media_files=[(str(img), False)],
caption="urgent notice",
)
)
assert result.get("success") is True, result
assert any(json and json.get("content") == "urgent notice" for _, json in posted), posted
assert result.get("warnings"), "media failure must be surfaced as a warning"
def test_non_caption_message_text_sent_before_failed_media_is_not_duplicated(self, tmp_path):
img = tmp_path / "x.png"
img.write_bytes(b"x")
posted = []
def _post(url, json):
if "getAppAccessToken" in url:
return SimpleNamespace(status_code=200, json=lambda: {"access_token": "at-1"})
posted.append((url, json))
return SimpleNamespace(status_code=200, json=lambda: {"id": "mid"})
with patch.dict("sys.modules", {"httpx": _token_client(_post)}), patch(
"tools.send_message_senders._qqbot_deliver_one_media",
new=AsyncMock(return_value={"error": "QQBot media send failed: too large"}),
):
result = asyncio.run(
_send_qqbot(
_pconfig(),
"user-openid",
"read this text",
media_files=[(str(img), False)],
)
)
# Main's contract: the already-delivered text survives; the media failure becomes a
# warning on the result, not a bare error that discards the send.
assert result.get("success") is True, result
contents = [json.get("content") for _, json in posted if json]
assert contents.count("read this text") == 1, posted
assert result.get("warnings"), "media failure must be surfaced as a warning"
class TestQqbotSendMediaMessageBody:
def test_body_uses_msg_type_media(self):
from tools.send_message_senders import _qqbot_send_media_message
captured = {}
async def _fake_api(client, headers, method, path, body=None, timeout=30.0):
captured["method"] = method
captured["path"] = path
captured["body"] = body
return {"id": "x"}
with patch(
"tools.send_message_senders._qqbot_api_json",
new=AsyncMock(side_effect=_fake_api),
):
asyncio.run(
_qqbot_send_media_message(
None,
{},
"c2c",
"u1",
"FILEINFO",
caption="hi",
)
)
assert captured["method"] == "POST"
assert captured["path"] == "/v2/users/u1/messages"
assert captured["body"]["msg_type"] == MSG_TYPE_MEDIA
assert captured["body"]["media"] == {"file_info": "FILEINFO"}
assert captured["body"]["content"] == "hi"