refactor(qqbot): extract protocol layer to qqbot-agent-sdk + migrate all bugfixes#21162
refactor(qqbot): extract protocol layer to qqbot-agent-sdk + migrate all bugfixes#21162WideLee wants to merge 4 commits into
Conversation
- Extract gateway/platforms/qqbot/ protocol layer (~5k lines) into standalone qqbot-agent-sdk package (MIT, PyPI) - Flatten qqbot/ package into single qqbot.py adapter (~1.2k lines) - Adapter delegates all QQ protocol logic to SDK: WebSocket lifecycle, HTTP API client, event parsing, media upload, approval keyboards, session persistence, attachment processing, audio STT pipeline - WebSocket runs in dedicated thread with independent asyncio loop, isolating QQBot from main gateway event loop - Configure SDK: source=hermes, UA includes Hermes/<version> - QR onboard rewritten as thin wrapper around SDK start_onboard() with expiry retry via OnboardExpiredError - Cache dir includes app_id for multi-bot isolation - Add qqbot-agent-sdk>=1.2.0 to pyproject.toml [qqbot] + [all] extras - SDK-internal tests removed; adapter integration tests retained (63 cases)
Migrated bugfixes from main branch's gateway/platforms/qqbot/adapter.py: ✅ Fixed in hermes adapter layer: - 762eb79: Add httpx keepalive limits to prevent CLOSE_WAIT accumulation * Applied platform_httpx_limits() to httpx.AsyncClient * Prevents socket leaks on macOS + Cloudflare Warp - d69a0b2: Add ACL checks for guild messages and guild DMs (SECURITY) * EventType.AT_MESSAGE_CREATE now checks group_policy ACL * EventType.DIRECT_MESSAGE_CREATE now checks dm_policy ACL * Prevents allowlist bypass via guild channels or DMs⚠️ TODO in qqbot-agent-sdk: 1. 0443484: WebSocket proxy support (CRITICAL) - Honor WSS_PROXY/HTTPS_PROXY/ALL_PROXY env vars - Set aiohttp.ClientSession(trust_env=True) - Pass proxy param to ws_connect() 2. ec7e920: Add backoff upper-bound check for QQCloseError - Add MAX_RECONNECT_ATTEMPTS check in non-4008 reconnect path - Prevents infinite retry loops 3. a00e471: Preserve original filename for quoted attachments - AttachmentDownloader.download() needs filename parameter - Pass to download_document() to preserve original name 4. d2206c6: Env var rename back-compat - Check old env var names for compatibility
Upgrade to qqbot-agent-sdk v1.2.2, which brings two improvements: 1. WebSocket proxy support (SDK commit 2b4aa7e) - Honors WSS_PROXY/HTTPS_PROXY/ALL_PROXY env vars - Fixes connection failures for WSL/corporate proxy users - Replaces the adapter-layer proxy handling (bugfix 0443484) 2. Unified voice attachment handling (SDK commit 9516fbf) - AttachmentProcessor now embeds STT transcripts directly in ProcessedAttachment.description - Adapter no longer needs special-case [Voice] prefix handling Changes in hermes-agent: - pyproject.toml: bump qqbot-agent-sdk constraint to >=1.2.2,<2 - gateway/platforms/qqbot.py: * Remove describe_attachment import (no longer needed) * Simplify _build_message_event: just append att.description for all kinds * Simplify _resolve_quote: route quoted attachments through AttachmentProcessor for consistent formatting (incl. STT transcripts) - tests/gateway/test_qqbot.py: * Update TestQQWebSocketProxy to test SDK's QQWebSocket.open() directly (proxy logic moved from adapter to SDK) Test results: - tests/gateway/test_qqbot.py: 64 passed - tests/tools/test_send_message_tool.py: 95 passed
install.sh currently fails with: Because only qqbot-agent-sdk==1.2.1 is available and hermes-agent[all] depends on qqbot-agent-sdk>=1.2.2,<2, we can conclude that hermes-agent[all] cannot be used. Root cause: [tool.uv] exclude-newer = "7 days" in pyproject.toml filters out PyPI releases newer than 7 days (supply-chain safety). SDK v1.2.2 was published on 2026-05-07, so PyPI-based resolution will stay blocked until ~2026-05-14. Fix: install qqbot-agent-sdk directly from the Git tag v1.2.2 (pinned to commit 6163b5d). Git URL dependencies bypass exclude-newer because they don't go through PyPI version resolution. This mirrors the pattern already used for atroposlib, tinker, and yc-bench under the [rl] and [yc-bench] extras. TODO: revert to `qqbot-agent-sdk>=1.2.2,<2` after 2026-05-14 once PyPI resolution is unblocked.
|
Hey @WideLee — first, thank you for the depth of work here. The adapter breakdown is careful, the bugfix migration table is exactly the kind of rigor we wish every contributor brought, and the net-new features (chunked uploads, structured upload errors, approval keyboard, WS session persistence, quoted-voice STT) are genuinely useful additions. I owe you an honest walk-back, though. When we first talked about this I said yes too quickly, and on a closer look I don't think I can take the SDK split path — not because of anything wrong with your code, but because of the shape of the dependency itself:
What I'd like to do instead: keep every substantive improvement you built, but land it as an in-tree PR against the existing
Your authorship will be preserved — every commit gets a Going to close this one and open a salvage PR against |
What does this PR do?
Extracts the QQ Bot protocol layer from
gateway/platforms/qqbot/into a standalone, reusable Python package: qqbot-agent-sdk (also on PyPI, MIT-licensed).Why this refactor?
The 2,413-line
gateway/platforms/qqbot/adapter.pyaccumulated a dense mix of:BasePlatformAdapterinterface, session-store integration, ACL enforcement)These two concerns were interleaved, which made the file hard to test, hard to reason about, and — critically — not reusable outside hermes-agent. Every bugfix (proxy support, reconnect bounds, STT unification) had to be re-implemented whenever someone built another QQ Bot integration.
This PR splits the two cleanly:
Code footprint in this repo
gateway/platforms/qqbot/subpackagegateway/platforms/qqbot.py(new)What's "gone" is now maintained in the SDK (6,125 LOC, 648 unit tests — significantly more rigorous than the previous monolith). The SDK also has its own CI, typing (PEP 561), and versioned releases.
Related Issue
Fixes #
(Internal refactor + feature consolidation; no external issue yet.)
Type of Change
Changes Made
1. Protocol extraction ♻️ (commit
0012a7b7f)Removed
gateway/platforms/qqbot/subpackage (6 files, 2,878 lines) in favor of a single thingateway/platforms/qqbot.py(1,233 lines) that delegates all protocol work toqqbot-agent-sdk.2. Bugfix consolidation 🐛🔒 (commit
dca731521)Reviewed every qqbot/adapter commit on
mainsince this branch forked and migrated them to the right layer:→ Kept in the hermes adapter layer (hermes-specific policy / infrastructure):
maind69a0b2c2🔒AT_MESSAGE_CREATEnow honorsgroup_policy,DIRECT_MESSAGE_CREATEhonorsdm_policy. Prevents allowlist bypass.762eb79f1platform_httpx_limits()to prevent CLOSE_WAIT socket leaks on macOS + Cloudflare Warp.→ Moved into qqbot-agent-sdk (generic protocol behavior):
main044348411WSS_PROXY/HTTPS_PROXY/ALL_PROXY,trust_env=True) — critical for WSL/corporate proxy users.QQWebSocket.open()ec7e92082MAX_RECONNECT_ATTEMPTSupper bound in the non-4008 reconnect branch — prevents infinite retry.QQWebSocket._listen_loop()a00e4716dAttachmentDownloader.download(url, ct, filename)d2206c69cQQ_*prefix convention).constants.py/audio.pycf55c738eonboard.start_onboard()3. SDK v1.2.2 integration ✨ (commit
42d326205)Adapts the adapter to use SDK v1.2.2's unified voice handling. See New capabilities below for everything this unlocks.
4. Dependency wiring 📦 (commit
93c9f7c35)pyproject.toml[qqbot]extra now pullsqqbot-agent-sdkfrom the Git tagv1.2.2. See Why Git URL? below.New capabilities 🚀
This PR is not just a refactor — by adopting the SDK, hermes-agent picks up several capabilities that didn't exist in the previous adapter, plus a handful of correctness/security hardening points:
🎯 Adapter-layer behavioral improvements
describe_attachment(ct, fname, cached)path that never ran STT — quoted voice messages showed[voice]with no text. Both main-body and quoted messages now go through the sameAttachmentProcessor, so the LLM sees identical transcript text regardless of position.app_id(~/.hermes/cache/qqbot/<app_id>/). Running two QQ bots on one hermes_home no longer mixes up downloaded media or session stores.📦 SDK-provided capabilities that the old adapter did not have
Verified by grepping the
mainadapter — these features are genuinely new, not code movement:POST /v2/users/{id}/fileswithfile_data(base64 in body) orurl— hard-capped by the platform at ~10 MB inline.ChunkedUploaderimplementsprepare → PUT parts → complete. Server decides part count; no client-side size threshold. Covers the QQ platform's full ~100 MB limit for images / videos / voice / arbitrary files.RuntimeError(str(...))on any failure — no way to distinguish "daily quota exhausted" from "file too big" from "network".UploadDailyLimitExceededErrorandUploadFileTooLargeErrorexposefile_name,file_size_human,limit_human— hermes surfaces actionable text to the user (e.g. "upload limit exceeded, retry tomorrow") instead of opaque HTTP codes.ApprovalSender+build_approval_keyboard(session_key)renders ✅ Allow once / ⭐ Always allow / ❌ Deny button markdown;parse_approval_button_data()decodes the user's click fromINTERACTION_CREATE. Unlocks QQ-native tool-use approvals.build_update_prompt_keyboard()+parse_update_prompt_button_data()— lets hermes prompt for update confirmations natively in QQ.Identifyand QQ replayed all missed events (READYstorm).WSSessionStoreserializessession_id+seq+bot_username+intentsto a JSON file on every heartbeat ACK. On restart the SDK attemptsResumefirst with age/intents validation, falling back toIdentifyonly when the stored session is stale.QQWebSocketowns a daemon thread + its own asyncio loop. Network I/O, reconnect backoff, and heartbeat are fully isolated; inbound callbacks are dispatched back to the main loop viarun_coroutine_threadsafe.d.get("content"),d.get("guild_id", ""), … in 4 different handlers.EventParser.parse(event_type, raw) → InboundEvent— one dataclass withchat_scope,chat_id,user_id,user_name,content,attachments,msg_elements,message_type,timestamp,raw. Used uniformly by the adapter.reply_to_text) — attachments inside quotes weren't downloaded or described.InboundEvent.msg_elements+MSG_TYPE_QUOTEexpose the quoted body and its attachments. Adapter runs them throughAttachmentProcessor, so quoted images / files / voice (with transcript) are now part of the LLM's context.STTPipeline.transcribe_with_path(att) → (transcript, cached_wav_path)returns both in one call and keeps the WAV in the downloader cache. Priority: configured STT → QQ's built-inasr_refer_text→ none.file_inforeuse for resendMediaUploader.upload(source=file_info)short-circuits the upload step when the caller already has a valid token — noticeably faster for broadcast-style sends.🛡️ Hardening delivered via the SDK
These are bugfixes that used to live only in the adapter (or not at all) and are now SDK-level guarantees — any future QQ integration (not just hermes) gets them for free:
MAX_RECONNECT_ATTEMPTScheck covers all non-4008 close codes (old adapter had a gap in theQQCloseErrorpath — bugec7e92082fixed that only recently onmain).044348411; SDK now implements it once and covers both aiohttp WS and httpx REST in the same code path.AttachmentDownloader.download(url, ct, filename)— keepsfoo.zipinstead of falling back to the CDN URL hash.[QQBot:<app_id>]. Side-by-side bots are distinguishable ingateway.log.📈 Developer-experience improvements
py.typed(PEP 561), mypy strict-mode clean.pip install qqbot-agent-sdkand skip reimplementing QQ plumbing; the SDK is not hermes-specific.Why install from a Git tag instead of PyPI?
The repo's
pyproject.tomlsets:This is a supply-chain safety policy —
uvrefuses to resolve any PyPI release less than 7 days old, which protects against freshly published malicious packages. Keeping this default is correct and this PR preserves it.The side effect:
qqbot-agent-sdk==1.2.2was released on 2026-05-07, so PyPI-based resolution won't unblock until ≈2026-05-14. In the meantimeinstall.shfails with:Could we relax to
>=1.2.0? No — that would letuvsilently install v1.2.1, which is missing three of the features that this PR's adapter code depends on:2b4aa7e)description(SDK9516fbf)The proxy regression is the nastiest — it only bites in production, without any traceback. No traceback, just "why won't my bot connect".
Chosen fix — Git URL pinned to the
v1.2.2tagexclude-newer(they skip PyPI version resolution entirely).v1.2.2→ commit6163b5d) — fully reproducible.atroposlib,tinker,yc-bench.install.sh;uv pip install -e ".[qqbot]"just works.Follow-up plan
After 2026-05-14, once v1.2.2 becomes PyPI-resolvable, flip to the standard version spec:
A
TODOto that effect is left inline inpyproject.tomland in commit93c9f7c35's message.How to Test
Install
Run the test suites
Smoke-test a live bot (needs QQ app credentials)
Proxy environment (optional, validates the bugfix migration)
Large-file upload (validates chunked-upload path)
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -q— 159 passed across the two QQ-relevant suites, no regressions elsewhereDocumentation & Housekeeping
cli-config.yaml.example— N/A (config keys unchanged)CONTRIBUTING.md/AGENTS.md— N/A (tree shape still matches;qqbot.pyis now a single file which is a subset of what the guidance already describes)send_messagesurface unchanged; adapter behaves identically from the tool's perspective)Screenshots / Logs
Commits on this branch