hermes-agent/tests/gateway/test_qqbot.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

1224 lines
44 KiB
Python

"""Tests for the QQ Bot platform adapter."""
import asyncio
import os
from types import SimpleNamespace
from unittest import mock
import httpx
import pytest
from gateway.config import PlatformConfig
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_config(**extra):
"""Build a PlatformConfig(enabled=True, extra=extra) for testing."""
return PlatformConfig(enabled=True, extra=extra)
# ---------------------------------------------------------------------------
# check_qq_requirements
# ---------------------------------------------------------------------------
class TestQQRequirements:
def test_returns_bool(self):
from gateway.platforms.qqbot import check_qq_requirements
result = check_qq_requirements()
assert isinstance(result, bool)
# ---------------------------------------------------------------------------
# QQAdapter.__init__
# ---------------------------------------------------------------------------
class TestQQAdapterInit:
def _make(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_env_fallback(self):
with mock.patch.dict(os.environ, {"QQ_APP_ID": "env_id", "QQ_CLIENT_SECRET": "env_sec"}, clear=False):
adapter = self._make()
assert adapter._app_id == "env_id"
assert adapter._client_secret == "env_sec"
def test_dm_policy_default(self):
adapter = self._make(app_id="a", client_secret="b")
assert adapter._dm_policy == "pairing"
def test_group_policy_default(self):
adapter = self._make(app_id="a", client_secret="b")
assert adapter._group_policy == "pairing"
def test_allow_from_parsing_string(self):
adapter = self._make(app_id="a", client_secret="b", allow_from="x, y , z")
assert adapter._allow_from == ["x", "y", "z"]
def test_markdown_support_default(self):
adapter = self._make(app_id="a", client_secret="b")
assert adapter._markdown_support is True
# ---------------------------------------------------------------------------
# _coerce_list
# ---------------------------------------------------------------------------
class TestCoerceList:
def _fn(self, value):
from gateway.platforms.qqbot import _coerce_list
return _coerce_list(value)
def test_none(self):
assert self._fn(None) == []
def test_string(self):
assert self._fn("a, b ,c") == ["a", "b", "c"]
# ---------------------------------------------------------------------------
# _is_voice_content_type
# ---------------------------------------------------------------------------
class TestIsVoiceContentType:
def _fn(self, content_type, filename):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter._is_voice_content_type(content_type, filename)
def test_voice_extension_fallback_when_content_type_empty(self):
"""content_type='' with audio extension → True (extension fallback)."""
assert self._fn("", "file.silk") is True
def test_audio_extension_amr_fallback_when_content_type_empty(self):
"""content_type='' with .amr extension → True (extension fallback)."""
assert self._fn("", "recording.amr") is True
# ---------------------------------------------------------------------------
# Voice attachment SSRF protection
# ---------------------------------------------------------------------------
class TestVoiceAttachmentSSRFProtection:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_connect_uses_redirect_guard_hook(self):
from gateway.platforms.qqbot import QQAdapter, _ssrf_redirect_guard
client = mock.AsyncMock()
with mock.patch("gateway.platforms.qqbot.adapter.httpx.AsyncClient", return_value=client) as async_client_cls:
adapter = QQAdapter(_make_config(app_id="a", client_secret="b"))
adapter._ensure_token = mock.AsyncMock(side_effect=RuntimeError("stop after client creation"))
connected = asyncio.run(adapter.connect())
assert connected is False
assert async_client_cls.call_count == 1
kwargs = async_client_cls.call_args.kwargs
assert kwargs.get("follow_redirects") is True
assert kwargs.get("event_hooks", {}).get("response") == [_ssrf_redirect_guard]
# ---------------------------------------------------------------------------
# Voice attachment temp-file cleanup
# ---------------------------------------------------------------------------
class TestVoiceAttachmentTempCleanup:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def _setup_download_mocks(self, adapter, content=b"RIFFmock-wav-audio-data"):
response = mock.Mock()
response.content = content
response.headers = {"content-type": "audio/wav"}
response.raise_for_status = mock.Mock()
adapter._http_client = mock.AsyncMock()
adapter._http_client.get = mock.AsyncMock(return_value=response)
def test_temp_wav_cleaned_up_on_stt_failure(self):
adapter = self._make_adapter(app_id="a", client_secret="b")
self._setup_download_mocks(adapter)
seen = {}
async def _raise_transport_error(path):
seen["wav_path"] = path
raise httpx.TransportError("boom")
with mock.patch("tools.url_safety.is_safe_url", return_value=True):
adapter._call_stt = mock.AsyncMock(side_effect=_raise_transport_error)
transcript = asyncio.run(
adapter._stt_voice_attachment(
"https://cdn.qq.com/voice.silk",
"audio/silk",
"voice.silk",
voice_wav_url="https://cdn.qq.com/voice.wav",
)
)
assert transcript is None
assert "wav_path" in seen
assert not os.path.exists(seen["wav_path"])
# ---------------------------------------------------------------------------
# WebSocket proxy handling
# ---------------------------------------------------------------------------
class TestQQWebSocketProxy:
@pytest.mark.asyncio
async def test_open_ws_honors_proxy_env(self, monkeypatch):
from gateway.platforms.qqbot import QQAdapter
for key in (
"WSS_PROXY",
"wss_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
adapter = QQAdapter(_make_config(app_id="a", client_secret="b"))
seen_session_kwargs = {}
seen_ws_kwargs = {}
class FakeSession:
def __init__(self, **kwargs):
seen_session_kwargs.update(kwargs)
self.closed = False
async def close(self):
self.closed = True
async def ws_connect(self, *args, **kwargs):
seen_ws_kwargs.update(kwargs)
return mock.AsyncMock(closed=False)
with mock.patch("gateway.platforms.qqbot.adapter.aiohttp.ClientSession", side_effect=FakeSession):
await adapter._open_ws("wss://api.sgroup.qq.com/websocket")
assert seen_session_kwargs.get("trust_env") is True
assert seen_ws_kwargs.get("proxy") == "http://127.0.0.1:7897"
# ---------------------------------------------------------------------------
# _strip_at_mention
# ---------------------------------------------------------------------------
class TestStripAtMention:
def _fn(self, content):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter._strip_at_mention(content)
def test_removes_mention(self):
result = self._fn("@BotUser hello there")
assert result == "hello there"
# ---------------------------------------------------------------------------
# _is_dm_allowed
# ---------------------------------------------------------------------------
class TestDmAllowed:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_open_policy_with_opt_in(self, monkeypatch):
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="open")
assert adapter._is_dm_allowed("any_user") is True
assert adapter._is_dm_intake_allowed("any_user") is True
def test_allowlist_match(self):
adapter = self._make_adapter(app_id="a", client_secret="b", dm_policy="allowlist", allow_from="user1,user2")
assert adapter._is_dm_allowed("user1") is True
# ---------------------------------------------------------------------------
# _is_group_allowed
# ---------------------------------------------------------------------------
class TestGroupAllowed:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_allowlist_match(self):
adapter = self._make_adapter(app_id="a", client_secret="b", group_policy="allowlist", group_allow_from="grp1")
assert adapter._is_group_allowed("grp1", "user1") is True
def test_pairing_default_blocks_groups(self):
adapter = self._make_adapter(app_id="a", client_secret="b")
assert adapter._group_policy == "pairing"
assert adapter._is_group_allowed("grp1", "user1") is False
# ---------------------------------------------------------------------------
# _resolve_stt_config
# ---------------------------------------------------------------------------
class TestResolveSTTConfig:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_no_config(self):
adapter = self._make_adapter(app_id="a", client_secret="b")
with mock.patch.dict(os.environ, {}, clear=True):
assert adapter._resolve_stt_config() is None
# ---------------------------------------------------------------------------
# _detect_message_type
# ---------------------------------------------------------------------------
class TestDetectMessageType:
def _fn(self, media_urls, media_types):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter._detect_message_type(media_urls, media_types)
def test_no_media(self):
from gateway.platforms.base import MessageType
assert self._fn([], []) == MessageType.TEXT
# ---------------------------------------------------------------------------
# QQCloseError
# ---------------------------------------------------------------------------
class TestQQCloseError:
def test_attributes(self):
from gateway.platforms.qqbot import QQCloseError
err = QQCloseError(4004, "bad token")
assert err.code == 4004
assert err.reason == "bad token"
# ---------------------------------------------------------------------------
# _dispatch_payload
# ---------------------------------------------------------------------------
class TestDispatchPayload:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
adapter = QQAdapter(_make_config(**extra))
return adapter
def test_unknown_op(self):
adapter = self._make_adapter(app_id="a", client_secret="b")
# Should not raise
adapter._dispatch_payload({"op": 99, "d": {}})
# last_seq should remain None
assert adapter._last_seq is None
def test_seq_increments(self):
adapter = self._make_adapter(app_id="a", client_secret="b")
adapter._dispatch_payload({"op": 0, "t": "READY", "s": 5, "d": {}})
adapter._dispatch_payload({"op": 0, "t": "SOME_EVENT", "s": 10, "d": {}})
assert adapter._last_seq == 10
# ---------------------------------------------------------------------------
# READY / RESUMED handling
# ---------------------------------------------------------------------------
class TestReadyHandling:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_ready_stores_session(self):
adapter = self._make_adapter(app_id="a", client_secret="b")
adapter._dispatch_payload({
"op": 0, "t": "READY",
"s": 1,
"d": {"session_id": "sess_abc123"},
})
assert adapter._session_id == "sess_abc123"
# ---------------------------------------------------------------------------
# _parse_json
# ---------------------------------------------------------------------------
class TestParseJson:
def _fn(self, raw):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter._parse_json(raw)
def test_valid_json(self):
result = self._fn('{"op": 10, "d": {}}')
assert result == {"op": 10, "d": {}}
def test_invalid_json(self):
result = self._fn("not json")
assert result is None
# ---------------------------------------------------------------------------
# _build_text_body
# ---------------------------------------------------------------------------
class TestBuildTextBody:
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
def test_plain_text(self):
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=False)
body = adapter._build_text_body("hello world")
assert body["msg_type"] == 0 # MSG_TYPE_TEXT
assert body["content"] == "hello world"
def test_markdown_text(self):
adapter = self._make_adapter(app_id="a", client_secret="b", markdown_support=True)
body = adapter._build_text_body("**bold** text")
assert body["msg_type"] == 2 # MSG_TYPE_MARKDOWN
assert body["markdown"]["content"] == "**bold** text"
# ---------------------------------------------------------------------------
# _wait_for_reconnection / send reconnection wait
# ---------------------------------------------------------------------------
class TestWaitForReconnection:
"""Test that send() waits for reconnection instead of silently dropping."""
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(**extra))
@pytest.mark.asyncio
async def test_send_waits_and_succeeds_on_reconnect(self):
"""send() should wait for reconnection and then deliver the message."""
adapter = self._make_adapter(app_id="a", client_secret="b")
# Initially disconnected
adapter._running = False
adapter._http_client = mock.MagicMock()
# Simulate reconnection after 0.3s (faster than real interval)
async def fake_api_request(*args, **kwargs):
return {"id": "msg_123"}
adapter._api_request = fake_api_request
adapter._ensure_token = mock.AsyncMock()
adapter._RECONNECT_POLL_INTERVAL = 0.1
adapter._RECONNECT_WAIT_SECONDS = 5.0
# Schedule reconnection after a short delay
async def reconnect_after_delay():
await asyncio.sleep(0.2)
adapter._running = True
adapter._ws = SimpleNamespace(closed=False)
asyncio.get_event_loop().create_task(reconnect_after_delay())
result = await adapter.send("test_openid", "Hello, world!")
assert result.success
assert result.message_id == "msg_123"
# ---------------------------------------------------------------------------
# ChunkedUploader
# ---------------------------------------------------------------------------
class TestChunkedUploadFormatSize:
def test_bytes(self):
from gateway.platforms.qqbot.chunked_upload import format_size
assert format_size(100) == "100.0 B"
class TestChunkedUploadErrors:
def test_too_large_includes_limit(self):
from gateway.platforms.qqbot.chunked_upload import UploadFileTooLargeError
exc = UploadFileTooLargeError("huge.bin", 200 * 1024 * 1024, 100 * 1024 * 1024)
assert exc.file_name == "huge.bin"
assert "MB" in exc.file_size_human
assert "MB" in exc.limit_human
assert "huge.bin" in str(exc)
class TestChunkedUploadHelpers:
def test_read_chunk_short_read_raises(self, tmp_path):
from gateway.platforms.qqbot.chunked_upload import _read_file_chunk
f = tmp_path / "x.bin"
f.write_bytes(b"hi")
with pytest.raises(IOError):
_read_file_chunk(str(f), 0, 100)
def test_parse_prepare_response_wrapped_in_data(self):
from gateway.platforms.qqbot.chunked_upload import _parse_prepare_response
raw = {
"data": {
"upload_id": "uid-42",
"block_size": 4096,
"parts": [
{"part_index": 1, "presigned_url": "https://cos/1", "block_size": 4096},
{"index": 2, "url": "https://cos/2"},
],
"concurrency": 3,
"retry_timeout": 90,
}
}
r = _parse_prepare_response(raw)
assert r.upload_id == "uid-42"
assert r.block_size == 4096
assert len(r.parts) == 2
assert r.parts[0].presigned_url == "https://cos/1"
assert r.parts[1].index == 2
assert r.concurrency == 3
assert r.retry_timeout == 90.0
class TestChunkedUploaderFlow:
"""End-to-end prepare / PUT / part_finish / complete flow with mocked HTTP.
Verifies the state machine matches the QQ v2 contract without hitting the network.
"""
@pytest.mark.asyncio
async def test_full_upload_two_parts_success(self, tmp_path):
from gateway.platforms.qqbot.chunked_upload import ChunkedUploader
# Two-part file.
f = tmp_path / "vid.mp4"
f.write_bytes(b"A" * 5_000_000 + b"B" * 3_000_000)
# Mock api_request — handles prepare, part_finish, complete based on URL.
api_calls = []
async def fake_api_request(method, path, *, body=None, timeout=None):
api_calls.append((method, path, body))
if path.endswith("/upload_prepare"):
return {
"upload_id": "uid-xyz",
"block_size": 5_000_000,
"parts": [
{"part_index": 1, "presigned_url": "https://cos.example/p1"},
{"part_index": 2, "presigned_url": "https://cos.example/p2"},
],
"concurrency": 1,
}
if path.endswith("/upload_part_finish"):
return {}
# complete
return {"file_info": "FILEINFO_TOKEN", "file_uuid": "u-1"}
# Mock http_put — always returns 200.
put_calls = []
class _FakeResp:
status_code = 200
text = ""
async def fake_put(url, data=None, headers=None):
put_calls.append((url, len(data), headers))
return _FakeResp()
uploader = ChunkedUploader(
api_request=fake_api_request,
http_put=fake_put,
log_tag="QQBot:TEST",
)
result = await uploader.upload(
chat_type="c2c",
target_id="user-openid-1",
file_path=str(f),
file_type=2, # MEDIA_TYPE_VIDEO
file_name="vid.mp4",
)
assert result["file_info"] == "FILEINFO_TOKEN"
# Two PUTs, one per part.
assert len(put_calls) == 2
assert put_calls[0][0] == "https://cos.example/p1"
assert put_calls[1][0] == "https://cos.example/p2"
# Prepare + 2 part_finish + complete = 4 api calls.
assert len(api_calls) == 4
assert api_calls[0][1].endswith("/upload_prepare")
assert api_calls[1][1].endswith("/upload_part_finish")
assert api_calls[2][1].endswith("/upload_part_finish")
# complete path reuses /files.
assert api_calls[3][1].endswith("/files")
assert api_calls[3][2] == {"upload_id": "uid-xyz"}
@pytest.mark.asyncio
async def test_group_paths(self, tmp_path):
"""Group uploads hit /v2/groups/... instead of /v2/users/..."""
from gateway.platforms.qqbot.chunked_upload import ChunkedUploader
f = tmp_path / "a.bin"
f.write_bytes(b"x" * 100)
seen_paths = []
async def fake_api_request(method, path, *, body=None, timeout=None):
seen_paths.append(path)
if path.endswith("/upload_prepare"):
return {
"upload_id": "gid-1",
"block_size": 100,
"parts": [{"part_index": 1, "presigned_url": "https://cos/g1"}],
}
if path.endswith("/upload_part_finish"):
return {}
return {"file_info": "GFILE"}
class _R:
status_code = 200
text = ""
async def fake_put(url, data=None, headers=None):
return _R()
u = ChunkedUploader(fake_api_request, fake_put, "QQBot:T")
await u.upload(
chat_type="group",
target_id="grp-openid-1",
file_path=str(f),
file_type=4,
file_name="a.bin",
)
assert all("/v2/groups/" in p for p in seen_paths)
assert any(p.endswith("/upload_prepare") for p in seen_paths)
assert any(p.endswith("/files") for p in seen_paths)
# ---------------------------------------------------------------------------
# Inline keyboards — approval + update-prompt flows
# ---------------------------------------------------------------------------
class TestApprovalButtonData:
def test_parse_allow_once(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
result = parse_approval_button_data("approve:agent:main:qqbot:c2c:UID:allow-once")
assert result == ("agent:main:qqbot:c2c:UID", "allow-once")
def test_parse_empty_returns_none(self):
from gateway.platforms.qqbot.keyboards import parse_approval_button_data
assert parse_approval_button_data("") is None
assert parse_approval_button_data(None) is None # type: ignore[arg-type]
class TestUpdatePromptButtonData:
def test_parse_yes(self):
from gateway.platforms.qqbot.keyboards import parse_update_prompt_button_data
assert parse_update_prompt_button_data("update_prompt:y") == "y"
class TestBuildApprovalKeyboard:
def test_three_buttons_in_single_row(self):
from gateway.platforms.qqbot.keyboards import build_approval_keyboard
kb = build_approval_keyboard("session-1")
assert len(kb.content.rows) == 1
assert len(kb.content.rows[0].buttons) == 3
def test_button_data_embeds_session_key(self):
from gateway.platforms.qqbot.keyboards import build_approval_keyboard
kb = build_approval_keyboard("agent:main:qqbot:c2c:UID")
datas = [b.action.data for b in kb.content.rows[0].buttons]
assert datas[0] == "approve:agent:main:qqbot:c2c:UID:allow-once"
assert datas[1] == "approve:agent:main:qqbot:c2c:UID:allow-always"
assert datas[2] == "approve:agent:main:qqbot:c2c:UID:deny"
class TestBuildUpdatePromptKeyboard:
def test_two_buttons(self):
from gateway.platforms.qqbot.keyboards import build_update_prompt_keyboard
kb = build_update_prompt_keyboard()
assert len(kb.content.rows[0].buttons) == 2
class TestBuildApprovalText:
def test_truncates_long_commands(self):
from gateway.platforms.qqbot.keyboards import (
ApprovalRequest, build_approval_text,
)
long = "x" * 1000
req = ApprovalRequest(
session_key="s", title="t", command_preview=long, cwd="/x",
)
text = build_approval_text(req)
# Preview is truncated to 300 chars; 1000 "x"s would still push the
# body past 300, but the inline preview specifically must be capped.
preview_line = [
line for line in text.split("\n") if line.startswith("```")
]
# 2 backtick fences; the content line in between is separate.
xs_in_preview = sum(line.count("x") for line in text.split("\n") if line and "```" not in line)
assert xs_in_preview <= 301 # 300 xs + one-off tolerance
class TestInteractionEventParsing:
def test_parse_c2c_interaction(self):
from gateway.platforms.qqbot.keyboards import parse_interaction_event
raw = {
"id": "interaction-42",
"chat_type": 2,
"user_openid": "user-1",
"data": {
"type": 11,
"resolved": {
"button_data": "approve:sess:allow-once",
"button_id": "allow",
},
},
}
ev = parse_interaction_event(raw)
assert ev.id == "interaction-42"
assert ev.scene == "c2c"
assert ev.chat_type == 2
assert ev.user_openid == "user-1"
assert ev.button_data == "approve:sess:allow-once"
assert ev.button_id == "allow"
assert ev.operator_openid == "user-1"
class TestAdapterInteractionDispatch:
"""End-to-end verification of _on_interaction including ACK + callback."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_callback_invoked_with_parsed_event(self):
adapter = self._make_adapter()
# Stub ACK so we don't require a live http_client.
ack_calls = []
async def fake_ack(interaction_id, code=0):
ack_calls.append((interaction_id, code))
adapter._acknowledge_interaction = fake_ack # type: ignore[assignment]
received = []
async def cb(event):
received.append(event)
adapter.set_interaction_callback(cb)
await adapter._on_interaction({
"id": "i-1",
"chat_type": 2,
"user_openid": "user-1",
"data": {
"type": 11,
"resolved": {"button_data": "approve:agent:main:qqbot:c2c:u:deny", "button_id": "deny"},
},
})
assert len(ack_calls) == 1
assert ack_calls[0][0] == "i-1"
assert len(received) == 1
assert received[0].button_data == "approve:agent:main:qqbot:c2c:u:deny"
assert received[0].scene == "c2c"
# ---------------------------------------------------------------------------
# Quoted-message handling (message_type=103 → msg_elements)
# ---------------------------------------------------------------------------
class TestProcessQuotedContext:
"""Verify the quoted-message pipeline: text + voice STT + images + files."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_non_quote_message_returns_empty(self):
adapter = self._make_adapter()
d = {"message_type": 0, "content": "hi"}
out = await adapter._process_quoted_context(d)
assert out == {"quote_block": "", "image_urls": [], "image_media_types": []}
@pytest.mark.asyncio
async def test_quote_with_voice_attachment_runs_stt(self):
adapter = self._make_adapter()
# Capture what attachments are passed into _process_attachments.
captured = []
async def fake_process(atts):
captured.append(atts)
return {
"image_urls": [],
"image_media_types": [],
"voice_transcripts": ["[Voice] hello from the quoted audio"],
"attachment_info": "",
}
adapter._process_attachments = fake_process # type: ignore[assignment]
d = {
"message_type": 103,
"msg_elements": [{
"content": "",
"attachments": [
{"content_type": "audio/silk",
"url": "https://qq-cdn/x.silk",
"filename": "rec.silk"}
],
}],
}
out = await adapter._process_quoted_context(d)
# The quoted voice attachment must actually flow through STT.
assert captured and len(captured[0]) == 1
assert captured[0][0]["content_type"] == "audio/silk"
assert "[Quoted message]:" in out["quote_block"]
assert "hello from the quoted audio" in out["quote_block"]
@pytest.mark.asyncio
async def test_multiple_elements_concatenated(self):
adapter = self._make_adapter()
async def fake_process(atts):
assert len(atts) == 2
return {
"image_urls": [], "image_media_types": [],
"voice_transcripts": [], "attachment_info": "",
}
adapter._process_attachments = fake_process # type: ignore[assignment]
d = {
"message_type": 103,
"msg_elements": [
{"content": "first", "attachments": [{"content_type": "image/png", "url": "a"}]},
{"content": "second", "attachments": [{"content_type": "image/png", "url": "b"}]},
],
}
out = await adapter._process_quoted_context(d)
assert "first" in out["quote_block"]
assert "second" in out["quote_block"]
class TestMergeQuoteInto:
def test_empty_quote_returns_original(self):
from gateway.platforms.qqbot.adapter import QQAdapter
assert QQAdapter._merge_quote_into("hello", "") == "hello"
# ---------------------------------------------------------------------------
# Gateway-contract approval UX — send_exec_approval + default dispatcher
# ---------------------------------------------------------------------------
class TestDefaultInteractionDispatch:
"""Verify the adapter's default INTERACTION_CREATE router."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
def test_default_callback_installed_on_init(self):
"""Fresh adapter has a working default interaction callback."""
adapter = self._make_adapter()
assert adapter._interaction_callback is not None
assert adapter._interaction_callback == adapter._default_interaction_dispatch
@pytest.mark.asyncio
async def test_approval_click_once_maps_to_once(self):
"""'allow-once' button → resolve_gateway_approval(session, 'once')."""
adapter = self._make_adapter()
resolve_calls = []
def fake_resolve(session_key, choice, resolve_all=False):
resolve_calls.append((session_key, choice, resolve_all))
return 1
# Patch the *module-level* function that _default_interaction_dispatch
# imports lazily.
import tools.approval
orig = tools.approval.resolve_gateway_approval
tools.approval.resolve_gateway_approval = fake_resolve
try:
from gateway.platforms.qqbot.keyboards import parse_interaction_event
event = parse_interaction_event({
"id": "i",
"chat_type": 2,
"user_openid": "u-42",
"data": {"resolved": {"button_data": "approve:agent:main:qqbot:c2c:u-42:allow-once"}},
})
await adapter._default_interaction_dispatch(event)
finally:
tools.approval.resolve_gateway_approval = orig
assert resolve_calls == [("agent:main:qqbot:c2c:u-42", "once", False)]
@pytest.mark.asyncio
async def test_approval_click_rejects_unauthorized_operator(self):
adapter = self._make_adapter()
resolve_calls = []
def fake_resolve(session_key, choice, resolve_all=False):
resolve_calls.append((session_key, choice, resolve_all))
return 1
import tools.approval
orig = tools.approval.resolve_gateway_approval
tools.approval.resolve_gateway_approval = fake_resolve
try:
from gateway.platforms.qqbot.keyboards import parse_interaction_event
event = parse_interaction_event({
"id": "i", "chat_type": 1,
"group_openid": "g-1",
"group_member_openid": "attacker",
"data": {"resolved": {"button_data": "approve:agent:main:qqbot:group:g-1:owner:allow-once"}},
})
await adapter._default_interaction_dispatch(event)
finally:
tools.approval.resolve_gateway_approval = orig
assert resolve_calls == []
@pytest.mark.asyncio
async def test_update_prompt_click_writes_response_file(self, tmp_path, monkeypatch):
"""update_prompt:y click writes 'y' to ~/.hermes/.update_response."""
adapter = self._make_adapter()
hermes_home = tmp_path / "hermes_home"
hermes_home.mkdir()
monkeypatch.setattr(
"hermes_constants.get_hermes_home",
lambda: hermes_home,
)
from gateway.platforms.qqbot.keyboards import parse_interaction_event
event = parse_interaction_event({
"id": "i", "chat_type": 2, "user_openid": "u-1",
"data": {"resolved": {"button_data": "update_prompt:y"}},
})
await adapter._default_interaction_dispatch(event)
response = hermes_home / ".update_response"
assert response.exists()
assert response.read_text() == "y"
class TestSendExecApproval:
"""Verify the gateway contract: QQAdapter.send_exec_approval(...)."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_delegates_to_send_approval_request(self):
adapter = self._make_adapter()
calls = []
async def fake_send_approval(chat_id, req, reply_to=None):
from gateway.platforms.base import SendResult
calls.append({"chat_id": chat_id, "req": req, "reply_to": reply_to})
return SendResult(success=True, message_id="m-1")
adapter.send_approval_request = fake_send_approval # type: ignore[assignment]
# Seed last-msg-id so the reply_to path is exercised.
adapter._last_msg_id["user-1"] = "inbound-42"
result = await adapter.send_exec_approval(
chat_id="user-1",
command="rm -rf /tmp/demo",
session_key="sess:abc",
description="delete temp dir",
)
assert result.success
assert len(calls) == 1
req = calls[0]["req"]
assert req.session_key == "sess:abc"
assert req.command_preview == "rm -rf /tmp/demo"
assert req.description == "delete temp dir"
assert calls[0]["reply_to"] == "inbound-42"
class TestSendUpdatePrompt:
"""Verify the cross-adapter send_update_prompt signature + behaviour."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_delegates_to_send_with_keyboard(self):
adapter = self._make_adapter()
captured = {}
async def fake_swk(chat_id, content, keyboard, reply_to=None):
from gateway.platforms.base import SendResult
captured["chat_id"] = chat_id
captured["content"] = content
captured["keyboard"] = keyboard
captured["reply_to"] = reply_to
return SendResult(success=True, message_id="mid")
adapter.send_with_keyboard = fake_swk # type: ignore[assignment]
adapter._last_msg_id["u1"] = "prev-msg"
result = await adapter.send_update_prompt(
chat_id="u1", prompt="Continue with update?",
default="y", session_key="ignored", metadata={"x": 1},
)
assert result.success
assert "Continue with update?" in captured["content"]
assert "default: y" in captured["content"]
assert captured["reply_to"] == "prev-msg"
# Keyboard has the Yes/No buttons.
dd = captured["keyboard"].to_dict()
datas = [b["action"]["data"] for b in dd["content"]["rows"][0]["buttons"]]
assert datas == ["update_prompt:y", "update_prompt:n"]
# ---------------------------------------------------------------------------
# _send_identify includes INTERACTION intent
# ---------------------------------------------------------------------------
class TestIdentifyIntents:
"""Verify the WebSocket identify payload includes the INTERACTION intent bit."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_intents_include_interaction_bit(self):
adapter = self._make_adapter()
# Mock token retrieval and WebSocket
adapter._access_token = "fake_token"
adapter._token_expires_at = 9999999999.0
sent_payloads = []
class FakeWS:
closed = False
async def send_json(self, payload):
sent_payloads.append(payload)
adapter._ws = FakeWS()
await adapter._send_identify()
assert len(sent_payloads) == 1
intents = sent_payloads[0]["d"]["intents"]
# Verify all expected intent bits are present
assert intents & (1 << 25), "GROUP_MESSAGES (1<<25) missing"
assert intents & (1 << 30), "GUILD_AT_MESSAGE (1<<30) missing"
assert intents & (1 << 12), "DIRECT_MESSAGES (1<<12) missing"
assert intents & (1 << 26), "INTERACTION (1<<26) missing"
# ---------------------------------------------------------------------------
# _process_attachments: video/file path exposure
# ---------------------------------------------------------------------------
class TestProcessAttachmentsPathExposure:
"""Verify that video and file attachments include the cached local path."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_video_attachment_includes_path(self):
adapter = self._make_adapter()
# Mock _download_and_cache to return a known path
async def fake_download(url, ct, original_name=""):
return "/tmp/cache/video_abc123.mp4"
adapter._download_and_cache = fake_download # type: ignore[assignment]
attachments = [
{
"content_type": "video/mp4",
"url": "https://multimedia.nt.qq.com.cn/download/video123",
"filename": "my_video.mp4",
}
]
result = await adapter._process_attachments(attachments)
assert result["image_urls"] == []
assert result["voice_transcripts"] == []
info = result["attachment_info"]
assert "[video:" in info
assert "my_video.mp4" in info
assert "/tmp/cache/video_abc123.mp4" in info
@pytest.mark.asyncio
async def test_quoted_video_includes_path_in_quote_block(self):
"""Quoted video attachments should surface the cached path in the quote block."""
adapter = self._make_adapter()
async def fake_process(atts):
# Simulate the fixed _process_attachments for a video attachment.
return {
"image_urls": [],
"image_media_types": [],
"voice_transcripts": [],
"attachment_info": "[video: clip.mp4 (/tmp/cache/clip.mp4)]",
}
adapter._process_attachments = fake_process # type: ignore[assignment]
d = {
"message_type": 103,
"msg_elements": [{
"content": "看看这个视频",
"attachments": [
{"content_type": "video/mp4",
"url": "https://qq-cdn/clip.mp4",
"filename": "clip.mp4"}
],
}],
}
out = await adapter._process_quoted_context(d)
assert "[Quoted message]:" in out["quote_block"]
assert "/tmp/cache/clip.mp4" in out["quote_block"]
# ---------------------------------------------------------------------------
# WebSocket op 7 (Server Reconnect) and op 9 (Invalid Session)
# ---------------------------------------------------------------------------
class TestOp7ServerReconnect:
"""Verify op 7 triggers WS close (which triggers reconnect in outer loop)."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
def test_op7_closes_websocket(self):
adapter = self._make_adapter()
adapter._session_id = "sess_keep"
adapter._last_seq = 42
close_called = []
class FakeWS:
closed = False
async def close(self):
close_called.append(True)
adapter._ws = FakeWS()
adapter._dispatch_payload({"op": 7, "d": None})
# Session should be preserved for Resume
assert adapter._session_id == "sess_keep"
assert adapter._last_seq == 42
# close() should have been scheduled
assert len(close_called) == 0 # _create_task schedules, not immediate
# But the task was created — verify via asyncio
class TestOp9InvalidSession:
"""Verify op 9 handles resumable vs non-resumable sessions."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
@pytest.mark.asyncio
async def test_op9_non_resumable_triggers_ws_close(self):
adapter = self._make_adapter()
adapter._session_id = "s"
adapter._last_seq = 1
close_called = []
class FakeWS:
closed = False
async def close(self):
close_called.append(True)
self.closed = True
adapter._ws = FakeWS()
adapter._dispatch_payload({"op": 9, "d": False})
await asyncio.sleep(0)
assert close_called == [True]
# ---------------------------------------------------------------------------
# Close code classification
# ---------------------------------------------------------------------------
class TestCloseCodeClassification:
"""Verify fatal close codes stop reconnecting and 4009 preserves session."""
def _make_adapter(self):
from gateway.platforms.qqbot.adapter import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b"))
def test_4009_preserves_session(self):
"""4009 (connection timeout) should NOT clear the session."""
adapter = self._make_adapter()
adapter._session_id = "sess_to_keep"
adapter._last_seq = 50
# The session-clearing codes set should NOT contain 4009.
# We verify the logic directly: dispatch a close-code event that
# exercises the session-clearing path (4006), then verify 4009 does not.
session_clear_codes = {
4006, 4007, 4900, 4901, 4902, 4903,
4904, 4905, 4906, 4907, 4908, 4909,
4910, 4911, 4912, 4913,
}
assert 4009 not in session_clear_codes
class TestReadEventsClosedWsGuard:
"""Regression: a closed-but-non-None ws must raise on entry, not return
normally, so _listen_loop goes through reconnect/backoff instead of
busy-looping at 100% CPU (issues #31193 / #31771)."""
def _make_adapter(self, **extra):
from gateway.platforms.qqbot import QQAdapter
return QQAdapter(_make_config(app_id="a", client_secret="b", **extra))
def test_read_events_raises_when_ws_closed_on_entry(self):
adapter = self._make_adapter()
adapter._running = True
adapter._ws = SimpleNamespace(closed=True)
with pytest.raises(RuntimeError):
asyncio.run(adapter._read_events())