Skip to content

feat(qqbot): port SDK improvements in-tree (chunked upload, approval keyboards, quoted attachments)#21342

Merged
teknium1 merged 3 commits into
mainfrom
hermes/hermes-d21fcddc
May 7, 2026
Merged

feat(qqbot): port SDK improvements in-tree (chunked upload, approval keyboards, quoted attachments)#21342
teknium1 merged 3 commits into
mainfrom
hermes/hermes-d21fcddc

Conversation

@teknium1
Copy link
Copy Markdown
Contributor

@teknium1 teknium1 commented May 7, 2026

Salvage PR for #21162. Ports the user-visible improvements from @WideLee's qqbot-agent-sdk back into the existing in-tree gateway/platforms/qqbot/ subpackage — no external Git-URL dependency, no new PyPI package to trust.

Summary

Three commits, each preserving WideLee's authorship via Co-authored-by:

# Commit What users feel
1 feat(qqbot): add chunked upload with structured error types Can send files up to the platform's ~100 MB per-file limit (was ~10 MB before). Daily-quota and size-limit errors surface as actionable text instead of opaque HTTP codes.
2 feat(qqbot): add inline-keyboard approvals and update prompts QQ-native tap-to-approve for tool use (✅ allow once / ⭐ always / ❌ deny), Yes/No update-confirmation prompts, and INTERACTION_CREATE wiring for button routing.
3 feat(qqbot): process attachments in quoted (reply) messages When a user quote-replies to a voice message, the LLM now sees the transcript. Quoted images are cached; quoted files keep their original filename.

Deliberately not ported (deferred — solutions to problems we haven't confirmed we have): WebSocket session persistence across restarts, dedicated-thread WebSocket, STT WAV-caching refactor. Happy to revisit if we see READY-storm or heartbeat-timeout 4009 complaints.

Why salvage instead of merging #21162

After a closer read of #21162 I wasn't comfortable taking a runtime dependency on a brand-new personal PyPI package published under a GitHub org name (tencent-connect) that isn't actually Tencent — and pulling it via Git URL specifically to bypass our exclude-newer = "7 days" uv policy sets a precedent I can't maintain consistently across the team. That policy exists to defend against freshly-published malicious packages. I explained this to WideLee directly in the close comment on #21162 and committed to porting the substantive work in-tree with full credit.

The code quality itself was fine; the structural concern was the dependency shape, not the logic.

Changes

 gateway/platforms/qqbot/__init__.py       |  36 ++
 gateway/platforms/qqbot/adapter.py        | 536 ++++++++++++++++--
 gateway/platforms/qqbot/chunked_upload.py | 603 ++++++++++++++++++++  (new)
 gateway/platforms/qqbot/keyboards.py      | 473 ++++++++++++++++     (new)
 tests/gateway/test_qqbot.py               | 892 ++++++++++++++++++++++++++++++
 5 files changed, 2509 insertions(+), 31 deletions(-)

Two new in-tree modules keep the adapter reviewable. No pyproject.toml change — no new runtime deps.

gateway/platforms/qqbot/chunked_upload.py

  • ChunkedUploader — prepare / PUT parts / complete state machine, decoupled from the adapter via api_request + http_put callables for isolated unit testing.
  • UploadDailyLimitExceededError (biz_code 40093002, non-retryable) and UploadFileTooLargeError with file_size_human / limit_human properties so the model can build user-friendly replies.
  • Handles biz_code 40093001 (retryable part_finish) with the server's retry timeout capped at 10 minutes.
  • Routed into QQAdapter._send_media for local files; URL uploads still take the fast inline path.

gateway/platforms/qqbot/keyboards.py

  • InlineKeyboard + button dataclasses that serialize to the JSON shape QQ expects.
  • build_approval_keyboard(session_key) — 3-button layout with group_id='approval' (clicking one greys the rest).
  • build_update_prompt_keyboard() — Yes/No for update confirms.
  • parse_approval_button_data() + parse_update_prompt_button_data() — decode button_data from INTERACTION_CREATE.
  • parse_interaction_event(raw) → InteractionEvent — one dataclass covering scene (guild/group/c2c), openids, button payload.

Adapter wiring (gateway/platforms/qqbot/adapter.py)

  • _dispatch_payload routes INTERACTION_CREATE_on_interaction → ACK via PUT /interactions/{id} → optional user callback. Callback exceptions are caught (never propagate into the WS loop).
  • set_interaction_callback(cb) registers a handler so gateway wiring can route approval/update-prompt clicks to their resolvers.
  • New public helpers: send_with_keyboard, send_approval_request, send_update_prompt.
  • _send_c2c_text / _send_group_text accept an optional keyboard kwarg.
  • _process_quoted_context extracts msg_elements (message_type=103) and runs their attachments through the same STT + image-cache pipeline as the main body. All four inbound handlers call it uniformly.

Validation

scripts/run_tests.sh tests/gateway/test_qqbot.py130 passed (72 existing + 58 new).

Area New tests
Chunked upload (format_size, hashes, helpers, full flow, error paths, retries) 19
Inline keyboards (button_data round-trip, keyboard shape, approval text, interaction dispatch) 27
Quoted-message handling (text / voice / image / file / multi-element / malformed) 12

Also ran tests/gateway/test_platform_http_client_limits.py alongside — no regressions (137 passed combined).

Attribution

All three commits have Co-authored-by: WideLee <limkuan24@gmail.com> and the commit author is WideLee — git log will attribute him correctly. Closes / salvages #21162.

WideLee added 3 commits May 7, 2026 07:22
The v2 'single POST /v2/{users|groups}/{id}/files' upload path is capped
at ~10 MB inline (base64 'file_data' or 'url'). For larger files the QQ
platform provides a three-step flow:

  1. POST /upload_prepare           → upload_id + pre-signed COS part URLs
  2. PUT each part to its COS URL → POST /upload_part_finish
  3. POST /files with {upload_id}   → file_info token

This commit adds a new gateway/platforms/qqbot/chunked_upload.py module
that implements the flow, wires it into QQAdapter._send_media for local
files (URL uploads keep the existing inline path), and introduces
structured exceptions so the caller can surface actionable error text:

- UploadDailyLimitExceededError  (biz_code 40093002, non-retryable)
- UploadFileTooLargeError        (file exceeds the platform limit)

Both carry file_name / file_size_human / limit_human so the model can
compose user-friendly replies instead of seeing opaque HTTP codes.

The part_finish 40093001 retryable-error loop respects the server-
provided retry_timeout (capped at 10 minutes locally) with a 1 s
polling interval. COS PUTs retry transient failures up to 2 times
with exponential backoff. complete_upload retries up to 2 times.

Covers files up to the platform's ~100 MB per-file limit; before this
the adapter silently rejected anything over ~10 MB.

19 new unit tests under TestChunkedUpload* cover the happy path,
prepare-response parsing, helper functions, part retries, COS PUT
retries, group vs c2c routing, and the structured-error mapping.

Co-authored-by: WideLee <limkuan24@gmail.com>
The QQ Bot v2 API supports inline keyboards on outbound messages. When a
user taps a button, the platform dispatches an INTERACTION_CREATE
gateway event; the bot ACKs it via PUT /interactions/{id} and decodes
the button's data payload to route the click.

This commit adds:

New module gateway/platforms/qqbot/keyboards.py

- Inline-keyboard dataclasses (InlineKeyboard, KeyboardRow, KeyboardButton,
  KeyboardButtonAction, KeyboardButtonRenderData, KeyboardButtonPermission)
  that serialize to the JSON shape the QQ API expects.
- build_approval_keyboard(session_key) — 3-button layout:
  ✅ 允许一次 / ⭐ 始终允许 / ❌ 拒绝, all sharing group_id='approval'
  so clicking one greys out the rest.
- build_update_prompt_keyboard() — Yes/No keyboard for update confirms.
- parse_approval_button_data() / parse_update_prompt_button_data() —
  decode the button_data payload from INTERACTION_CREATE.
  approve:<session_key>:<decision>  (decision = allow-once|allow-always|deny)
  update_prompt:<answer>            (answer = y|n)
- build_approval_text(ApprovalRequest) — markdown renderer for the
  surrounding message body (exec-approval and plugin-approval variants,
  with severity icons 🔴/🔵/🟡).
- parse_interaction_event(raw) → InteractionEvent dataclass — normalizes
  the nested raw payload (id / scene / openids / button_data / etc.).

Adapter changes (gateway/platforms/qqbot/adapter.py)

- _dispatch_payload routes INTERACTION_CREATE → _on_interaction.
- _on_interaction parses the event, ACKs via PUT /interactions/{id}, then
  invokes a user-registered interaction callback. Exceptions from the
  callback are caught and logged (never propagate into the WS loop).
- set_interaction_callback(cb) lets gateway wiring register a routing
  handler that inspects button_data and resolves the corresponding
  pending approval / update prompt.
- _send_c2c_text / _send_group_text now accept an optional keyboard kwarg
  and append it to the outbound body.
- send_with_keyboard(chat_id, content, keyboard, reply_to=None) — public
  helper that sends a single short message with a keyboard attached.
  Does NOT chunk-split (a keyboard message has one interactive surface).
  Guild chats are rejected non-retryably — they don't support keyboards.
- send_approval_request(chat_id, ApprovalRequest, reply_to=None) +
  send_update_prompt(chat_id, content, reply_to=None) — convenience
  wrappers over send_with_keyboard.

Tests

27 new unit tests under TestApprovalButtonData, TestUpdatePromptButtonData,
TestBuildApprovalKeyboard, TestBuildUpdatePromptKeyboard, TestBuildApprovalText,
TestInteractionEventParsing, and TestAdapterInteractionDispatch. Cover:

- Button-data round-trip (build → parse returns original session/decision)
- Keyboard JSON shape + mutual-exclusion group_id
- Exec vs plugin approval text templates + severity icons
- Interaction event parsing (c2c / group / guild scene codes)
- _on_interaction end-to-end: ACK invoked, callback receives parsed event,
  callback exceptions are swallowed, missing id skips ACK, no registered
  callback is harmless.

Full qqbot suite: 118 passed (72 existing + 19 chunked + 27 keyboards).

Co-authored-by: WideLee <limkuan24@gmail.com>
When a user replies while quoting another message, QQ sets
'message_type = 103' and pushes the referenced message's content +
attachments inside 'msg_elements[0]'. The old adapter ignored
msg_elements entirely, so:

- Bare quote-replies (no user text) surfaced nothing to the LLM.
- Quoted images/files/voice were never downloaded or described.
- Quoted voice messages specifically produced no transcript — the model
  had no way to see what the user was referring to when saying 'about
  this voice note…'.

This commit adds _process_quoted_context(d) which extracts msg_elements,
unions their attachments, and runs them through the SAME
_process_attachments pipeline as the main message body. Quoted voice
gets an STT transcript (tried via QQ's asr_refer_text first, then the
configured STT provider); quoted images get cached just like main-body
images; quoted files surface with their original filename intact (not
the CDN URL hash).

The quoted content is prepended to the user's text as a '[Quoted message]:'
block so the LLM sees the full referential context on one turn.
Images-only quotes surface a '[Quoted message]: (image)' marker so the
model knows an image was referenced even if no text came with it.

All four inbound handlers (_handle_c2c_message, _handle_group_message,
_handle_guild_message, _handle_dm_message) now call the helper uniformly
— one merge pattern, not four divergent implementations.

Filename preservation is carried by _process_attachments' existing
'[Attachment: {filename or ct}]' line; nothing else needed for that.

12 new tests under TestProcessQuotedContext and TestMergeQuoteInto cover:

- Non-quote messages short-circuit to empty
- message_type=103 with no msg_elements is harmless
- Text-only quotes render with '[Quoted message]:' prefix
- Voice attachments in the quote flow through STT
- File attachments in the quote preserve the original filename
- Image attachments surface cached paths + media types
- Images-only quote still emits a marker
- Multiple msg_elements are concatenated
- Malformed message_type values return empty
- _merge_quote_into prepends with a blank-line separator

Full qqbot suite: 130 passed (72 existing + 19 chunked + 27 keyboards
+ 12 quoted).

Co-authored-by: WideLee <limkuan24@gmail.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

🔎 Lint report: hermes/hermes-d21fcddc vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 7632 on HEAD, 7624 on base (🆕 +8)

🆕 New issues (6):

Rule Count
invalid-argument-type 5
unresolved-attribute 1
First entries
tests/gateway/test_qqbot.py:788: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `Unknown | None`
run_agent.py:12368: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (dict[Unknown, Unknown] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 3 union elements`
tests/gateway/test_qqbot.py:1009: [invalid-argument-type] invalid-argument-type: Argument to function `parse_approval_button_data` is incorrect: Expected `str`, found `None`
run_agent.py:12365: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 3 union elements`
run_agent.py:6504: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 3 union elements`
gateway/platforms/qqbot/chunked_upload.py:186: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `None` in union `Any | None | dict[str, Any]`

✅ Fixed issues (3):

Rule Count
invalid-argument-type 3
First entries
run_agent.py:6504: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 3 union elements`
run_agent.py:12368: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 3 union elements`
run_agent.py:12365: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 3 union elements`

Unchanged: 4002 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@teknium1 teknium1 merged commit 5b121c6 into main May 7, 2026
11 of 12 checks passed
@teknium1 teknium1 deleted the hermes/hermes-d21fcddc branch May 7, 2026 14:36
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have platform/qqbot QQ Bot adapter comp/gateway Gateway runner, session dispatch, delivery labels May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/qqbot QQ Bot adapter type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants