Skip to content

feat(qqbot): wire native tool-approval UX via inline keyboards#21353

Merged
teknium1 merged 1 commit into
mainfrom
hermes/hermes-d21fcddc
May 7, 2026
Merged

feat(qqbot): wire native tool-approval UX via inline keyboards#21353
teknium1 merged 1 commit into
mainfrom
hermes/hermes-d21fcddc

Conversation

@teknium1

@teknium1 teknium1 commented May 7, 2026

Copy link
Copy Markdown
Contributor

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_sync detects button-capable adapters via type(adapter).send_exec_approval and falls back to plain text otherwise. Without this wiring, QQ users stared at a text-based /approve prompt even though the adapter already shipped inline keyboards (a dead feature). This PR:

  1. Implements send_exec_approval(chat_id, command, session_key, description, metadata) — matches Discord / Telegram / Slack / Matrix / Feishu signature. Renders an ApprovalRequest with command preview + description + timeout, delegates to the existing send_approval_request helper. Uses the last inbound msg_id as reply_to so QQ accepts the passive message.
  2. Rewrites send_update_prompt to match the cross-adapter contract (chat_id, prompt, default, session_key, metadata) used by hermes update --gateway.
  3. Auto-installs _default_interaction_dispatch as the adapter's interaction callback in __init__. Routes approve:<session_key>:<decision> clicks to tools.approval.resolve_gateway_approval (button → choice mapping: allow-once → "once", allow-always → "always", deny → "deny") and update_prompt:<answer> clicks to an atomic write of y/n to ~/.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 session text command. Open to feedback if you'd rather split to 4 buttons on QQ.

Changes

 gateway/platforms/qqbot/adapter.py | ~300 lines added / 7 removed
 tests/gateway/test_qqbot.py        | +14 tests

Validation

scripts/run_tests.sh tests/gateway/test_qqbot.py144 passed (130 existing + 14 new).
scripts/run_tests.sh tests/gateway/test_qqbot.py tests/tools/test_approval.py276 passed combined, no regressions in the approval subsystem.

Tests cover: default callback installed on init, send_exec_approval / send_update_prompt detectable as class methods, each button decision maps to the right resolve choice, update-prompt clicks write atomically via monkeypatched get_hermes_home, unknown button_data / empty button_data / resolve exceptions are all harmless, send_exec_approval honours last_msg_id reply-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.

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>
@teknium1 teknium1 merged commit 4de3ef3 into main May 7, 2026
10 of 11 checks passed
@teknium1 teknium1 deleted the hermes/hermes-d21fcddc branch May 7, 2026 14:48
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

🔎 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: 7638 on HEAD, 7634 on base (🆕 +4)

🆕 New issues (2):

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.

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