feat(qqbot): wire native tool-approval UX via inline keyboards#21353
Merged
Conversation
Makes the in-tree QQ inline keyboards actually light up when the agent
blocks on a dangerous-command approval. Matches the cross-adapter
gateway contract already implemented by Discord, Telegram, Slack,
Matrix, and Feishu.
Gateway/run.py's _approval_notify_sync checks type(adapter).send_exec_approval
and falls back to a text prompt when it's missing. Without this wiring,
QQ users stared at plain '/approve' text even though the adapter shipped
button primitives.
### send_exec_approval(chat_id, command, session_key, description, metadata)
Matches the signature the gateway calls with. Builds an ApprovalRequest
(command_preview, description, timeout) and delegates to send_approval_request.
Uses the last inbound msg_id as reply_to so QQ accepts the passive
message. The 'metadata' parameter is accepted for contract parity but
intentionally unused — QQ doesn't have thread_id/DM-targeting overrides.
### send_update_prompt(chat_id, prompt, default, session_key, metadata)
Signature updated to match the cross-adapter contract used by
'hermes update --gateway' watcher. Renders a 'Update Needs Your Input'
prompt with the optional default hint and a Yes/No keyboard. Replaces
the earlier 3-arg helper that wasn't wired anywhere.
### Default interaction dispatcher
_default_interaction_dispatch() auto-registered as the adapter's
interaction callback in __init__. Routes:
- approve:<session_key>:<decision> → tools.approval.resolve_gateway_approval
Button → choice mapping:
allow-once → 'once'
allow-always → 'always'
deny → 'deny'
(QQ's 3-button mobile layout deliberately collapses 'session' + 'always'
into one button; /approve session text fallback remains available.)
- update_prompt:<answer> → atomic write of y/n to ~/.hermes/.update_response
(the detached 'hermes update --gateway' watcher polls this file)
- anything else → logged and dropped
Resolve exceptions are caught and logged — never propagate into the WS
loop. Callers can override via set_interaction_callback() to route
clicks elsewhere or pass None to drop them entirely.
### Net effect
QQ users now get native tap-to-approve UX on dangerous-command prompts
and update-confirmation prompts, without having to type /approve or /deny
as text. The adapter hooks into tools.approval the same way every other
button-capable platform does.
### Tests
14 new tests cover:
- Default callback installed on __init__
- send_exec_approval / send_update_prompt exist as class methods (so the
gateway's type-probe detects them)
- allow-once/always/deny each map to the correct resolve choice
- update_prompt:y / update_prompt:n each write atomically to the response
file (via monkeypatched get_hermes_home)
- Unknown button_data / empty button_data / resolve exceptions are harmless
- send_exec_approval honours last_msg_id reply-to and accepts metadata
- send_update_prompt delegates with correct content + keyboard
Full qqbot suite: 144 passed (72 pre-existing + 72 from this salvage arc).
Also ran tools/test_approval.py alongside — no regressions (276 passed
combined).
Co-authored-by: WideLee <limkuan24@gmail.com>
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-assignment |
2 |
First entries
tests/gateway/test_qqbot.py:1613: [invalid-assignment] invalid-assignment: Object of type `def fake_resolve(session_key, choice, resolve_all=False) -> Unknown` is not assignable to attribute `resolve_gateway_approval` of type `def resolve_gateway_approval(session_key: str, choice: str, resolve_all: bool = False) -> int`
tests/gateway/test_qqbot.py:1695: [invalid-assignment] invalid-assignment: Object of type `def bad_resolve(session_key, choice, resolve_all=False) -> Unknown` is not assignable to attribute `resolve_gateway_approval` of type `def resolve_gateway_approval(session_key: str, choice: str, resolve_all: bool = False) -> int`
✅ Fixed issues: none
Unchanged: 4010 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
1 task
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.
Follow-up to PR #21342. Now that the QQ adapter ships inline-keyboard primitives in-tree, hook them into the cross-adapter gateway contract so QQ users get native tap-to-approve UX on dangerous-command prompts (and the Yes/No update-confirm flow).
Summary
gateway/run.py's_approval_notify_syncdetects button-capable adapters viatype(adapter).send_exec_approvaland falls back to plain text otherwise. Without this wiring, QQ users stared at a text-based/approveprompt even though the adapter already shipped inline keyboards (a dead feature). This PR:send_exec_approval(chat_id, command, session_key, description, metadata)— matches Discord / Telegram / Slack / Matrix / Feishu signature. Renders anApprovalRequestwith command preview + description + timeout, delegates to the existingsend_approval_requesthelper. Uses the last inboundmsg_idasreply_toso QQ accepts the passive message.send_update_promptto match the cross-adapter contract (chat_id, prompt, default, session_key, metadata) used byhermes update --gateway._default_interaction_dispatchas the adapter's interaction callback in__init__. Routesapprove:<session_key>:<decision>clicks totools.approval.resolve_gateway_approval(button → choice mapping:allow-once → "once",allow-always → "always",deny → "deny") andupdate_prompt:<answer>clicks to an atomic write ofy/nto~/.hermes/.update_response. Resolve exceptions are swallowed so a bad callback can't take down the WS loop.Why
allow-always→"always"and not"session"Discord has four buttons (once / session / always / deny). QQ's mobile UI is narrower — WideLee's SDK deliberately shipped a 3-button layout, which I preserved on merge. The mapping keeps the 3-button design, sends permanent-approval when the user taps
⭐ 始终允许, and users wanting session-scoped approval can still fall back to the/approve sessiontext command. Open to feedback if you'd rather split to 4 buttons on QQ.Changes
Validation
scripts/run_tests.sh tests/gateway/test_qqbot.py— 144 passed (130 existing + 14 new).scripts/run_tests.sh tests/gateway/test_qqbot.py tests/tools/test_approval.py— 276 passed combined, no regressions in the approval subsystem.Tests cover: default callback installed on init,
send_exec_approval/send_update_promptdetectable as class methods, each button decision maps to the right resolve choice, update-prompt clicks write atomically via monkeypatchedget_hermes_home, unknown button_data / empty button_data / resolve exceptions are all harmless,send_exec_approvalhonourslast_msg_idreply-to and accepts metadata parity arg.Attribution
Same deal as #21342 — commit authored as WideLee,
Co-authored-by: WideLee <limkuan24@gmail.com>trailer. This is the piece of #21162's original intent that #21342 left as a follow-up.