fix(qqbot): route guild DMs, classify inbound documents, and harden send/connect errors - #35646
Open
lambertian wants to merge 1 commit into
Open
fix(qqbot): route guild DMs, classify inbound documents, and harden send/connect errors#35646lambertian wants to merge 1 commit into
lambertian wants to merge 1 commit into
Conversation
…end/connect errors
Fixes five independently-verified bugs in the QQ Bot adapter, each with a
regression test:
1. Guild direct-message replies were silently dropped: _handle_dm_message
stamped chat_type "dm", but no send path understood it. _send_chunk hit
"Unknown chat type" and _send_media POSTed the guild_id to the group
endpoint. Add _send_dm_text (POST /dms/{guild_id}/messages), a "dm" branch
in _send_chunk, and extend _send_media's guard to {"guild", "dm"}.
2. Inbound file attachments were never classified DOCUMENT: they only entered
attachment_info text with the raw host cache path, so the run.py
inbound-document routing (path-translation + document note) never fired.
Surface cached file paths via media_urls/media_types and return
MessageType.DOCUMENT for application/* and text/* leading types. Videos
stay text-only (run.py's document path handles only application/text). The
no-attachment early return carries the two keys too, so the text-only path
does not KeyError.
3. Send retry/permanent-error classification keyed on English keywords the QQ
API never returns (it replies in Chinese), so permanent 4xx were retried 3x
and reported retryable=True; the break-set and final flag also disagreed on
"bad request". Classify on the HTTP status _api_request embeds as
[<status>] via _is_permanent_send_error, shared by both decisions.
4. _api_request parsed resp.json() before the status check, raising a raw
JSONDecodeError on non-JSON 4xx/5xx bodies that bypassed the structured
RuntimeError (and the chunked_upload daily-limit detection). Check status
first; read the error body defensively (json then text fallback).
5. connect() marked permanent failures retryable=True: a missing aiohttp/httpx
install and a 401/403 from the token/gateway fetch were retried forever. Set
the missing-dependency branches retryable=False and escalate 401/403 to a
non-retryable qq_auth_failed via _is_auth_failure; genuine network errors
stay qq_connect_error/retryable=True.
lambertian
force-pushed
the
fix/qqbot-routing-and-errors
branch
from
May 31, 2026 02:56
e45c938 to
0de6f51
Compare
tonydwb
approved these changes
May 31, 2026
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved ✅ — QQBot platform correctness fixes.
Changes
Route guild DMs correctly, classify inbound documents, harden send/connect error handling.
Reviewed by Hermes Agent
teknium1
reviewed
Jul 13, 2026
teknium1
left a comment
Contributor
There was a problem hiding this comment.
Thanks for the focused QQBot regression coverage. The guild-DM, attachment, API-status, text-send, and connect premises are still present on current main.
Problems
- The new
_is_permanent_send_errorhelper is only used by text_send_chunk(gateway/platforms/qqbot/adapter.pyPR lines 2532-2557). Media URL sends still go through_upload_media; current main lines 2393-2409 retry a structured Chinese-message[403]because the guard only recognizes400,401, and selected English words. This leaves a permanent outbound-media failure retried three times.
Suggested changes
- Reuse the status classifier in
_upload_mediaand add a[403] ... 无权限media-upload regression test. - Preserve current main's
connect(*, is_reconnect: bool = False)contract atgateway/platforms/qqbot/adapter.py:281when salvaging; it was added in276542c729c10ff9d093760897f4c2d1256a79ce.
Automated hermes-sweeper review.
| return False | ||
|
|
||
| @staticmethod | ||
| def _is_permanent_send_error(exc: BaseException) -> bool: |
Contributor
There was a problem hiding this comment.
_is_permanent_send_error is only called from _send_chunk. Please apply it in _upload_media too: its existing retry loop still retries a structured Chinese-message [403] because it only detects 400, 401, and English keywords.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(qqbot): route guild DMs, classify inbound documents, and harden send/connect error handling
Depth over breadth: this fixes 5 independently-verified bugs in the QQ Bot adapter, each with a focused regression test that is red on the current tree and green after the change. The collapsed findings (7 raw items → 5 distinct bugs; two pairs were duplicates) all reproduce against
gateway/platforms/qqbot/adapter.pyas it stands today.1. Guild direct-message replies are silently dropped (no
dmsend branch)Symptom: A user direct-messages the bot inside a guild; the agent processes the message and generates a reply, but the reply never reaches the user. Media replies hit the wrong endpoint and 404.
Root cause:
_handle_dm_messagestamps the routing mapself._chat_type_map[guild_id] = "dm", but every outbound path resolves the kind via_guess_chat_type()and none understood"dm"._send_chunkonly branched onc2c/group/guild, so a guild DM fell into theelsereturningSendResult(success=False, error="Unknown chat type …")._send_mediaonly short-circuitedchat_type == "guild", so a DM fell through and POSTed theguild_idto/v2/groups/{id}/messages— the wrong endpoint.Fix: Add a
_send_dm_texthelper that POSTs to the correct guild-DM endpointPOST /dms/{guild_id}/messages(mirroring the existing_send_guild_textpattern), wire a"dm"branch into_send_chunk, and extend_send_media's guard to short-circuit{"guild", "dm"}so a DM media send never reaches the group endpoint.2. Inbound file attachments are never classified
DOCUMENTSymptom: A user sends a PDF/spreadsheet/generic file. The agent receives an inline text marker
[file: name (<host cache path>)]and the raw host path embedded in the body. Under a sandboxed agent backend that path is in the wrong namespace and the file cannot be opened.Root cause: Non-image, non-voice attachments were appended only to
attachment_infotext; they never enteredmedia_urls/media_types, and_detect_message_typecould only returnVOICE/VIDEO/PHOTO/TEXT— neverDOCUMENT. The inbound-document routing ingateway/run.pyfires only whenevent.media_urlsis non-empty ANDevent.message_type == MessageType.DOCUMENT; it then path-translates the host cache path to the agent-visible path and emits the standard "[The user sent a document …]" note. QQ files satisfied neither condition, so that handling never ran.Fix: In
_process_attachments, also append cached file paths tomedia_urls/media_types(real MIME, falling back toapplication/octet-stream) while keeping the human-readable marker. The no-attachment early-return dict carries the same two keys, so the text-only path (every handler readsatt_result["media_urls"]/["media_types"]unconditionally) does notKeyError. Extend_detect_message_typeto returnMessageType.DOCUMENTfor anapplication/*ortext/*leading type. Videos stay text-only on purpose — therun.pydocument path only handlesapplication/*andtext/*. Images remain first in the media list, so image-led events keep theirPHOTOclassification.3. Send retry/permanent-error classification keyed on English keywords the QQ API never returns
Symptom: A permanently-failing send (403 to a user the bot may not message, 404 unknown target) is retried 3× with back-off and then reported
retryable=True, so the gateway re-queues a doomed send.Root cause:
_send_chunkdecided retry-vs-permanent by substring-matching the lowercased exception against('invalid','forbidden','not found','bad request'). The QQ Bot v2 REST API returns its errormessagefield in Chinese, so none of those keywords match a genuine 4xx. There was also an internal inconsistency: the break-set included'bad request'but the finalretryablerecompute omitted it, so an Englishbad request400 could break the loop yet still be flaggedretryable=True.Fix: Classify on the HTTP status
_api_requestalready embeds as[<status>]. New_is_permanent_send_errortreats 4xx (except 429) as permanent and 429/5xx/timeouts/transport errors as transient, with a keyword fallback for local (non-HTTP) errors. Both the loop's break decision and the finalretryableflag now call the same classifier, so they cannot disagree.4.
_api_requestparsedresp.json()before checking statusSymptom: A 4xx/5xx with a non-JSON body (CDN/proxy HTML page, empty 429) raised a raw
JSONDecodeErrorinstead of the structuredRuntimeError. That broke the daily-quota detection inchunked_upload(which matches the40093002biz code in the message) and left the send classifier with a meaningless decode-error string.Root cause:
data = resp.json()ran before theif resp.status_code >= 400check; onlyhttpx.TimeoutExceptionwas caught, so the decode error escaped unwrapped.Fix: Check the status first. On
>= 400, build the message from a guarded body read (resp.json()with a fallback toresp.text[:200]), then raise the structuredQQ Bot API error [<status>] …. The success body is parsed only after the status check.5. Permanent
connect()failures markedretryable=Trueand retried foreverSymptom: A missing
aiohttp/httpxinstall, or a wrongapp_id/client_secret, leaves the operator status stuck on "retrying" while the reconnect watcher hammers the token endpoint at the back-off cap indefinitely.Root cause: The missing-dependency branches set
retryable=True(a missing pip package cannot self-heal by retrying), and the broadexcept Exceptionbucketed a 401/403 from_ensure_token()/_get_gateway_url()into the retryableqq_connect_errorcatch-all.Fix: Set
retryable=Falseon the missing-dependency branches. Add_is_auth_failure, which walks the exception cause chain for anhttpx.HTTPStatusErrorof 401/403 (the token fetch wraps it in a genericRuntimeErrorviafrom exc, so the status survives on__cause__). On an auth failure, escalate to a non-retryableqq_auth_failedfatal; genuine network/DNS/5xx/timeout errors stayqq_connect_error/retryable=True. This matches the convention inweixin.py/whatsapp.py/telegram.py.Overlap
qq_missing_credentialsretryable True→Falseedit plus an equivalent test. This PR does not touch theqq_missing_credentialsline; its connect hardening (fix 5) is a superset that instead covers the missing-dependency branches and escalates 401/403 toqq_auth_failedvia_is_auth_failure. The credential hunk overlaps in spirit (sameconnect()non-retryable direction), but the missing-dependency and auth-failure escalation here are distinct._detect_message_type, so the event is never classifiedDOCUMENTand thegateway/run.pypath-translation + document note still do not fire. Fix 2 here is the superset that actually triggers that routing: it adds theDOCUMENTclassification and scopes videos out (the run.py document path handles onlyapplication/*andtext/*)._is_authorized_interaction_for_session(the interaction-approvaldmchat_type), not the outbound send path; they do not overlap fix 1.tools/send_message_tool.py; it does not add a guild-DM send branch, the document classification, or the status-based send/connect classifiers. No functional overlap (a textual merge against its adapter rewrite is possible).