feat(plugins): pre_callback_dispatch hook for Telegram callback queries - #34539
Closed
sh940701 wants to merge 1 commit into
Closed
feat(plugins): pre_callback_dispatch hook for Telegram callback queries#34539sh940701 wants to merge 1 commit into
sh940701 wants to merge 1 commit into
Conversation
Mirror of pre_gateway_dispatch action semantics for the inline-keyboard
click path. Fired at the TOP of TelegramAdapter._handle_callback_query
for every inbound callback query, BEFORE any built-in prefix branch
(model picker, gmail triage, exec approval, slash confirm, clarify, …)
runs.
Plugins return action dicts to influence flow:
{"action": "skip", "reason": "...",
"answer_text": "..." (optional)} # if present, core awaits
# query.answer(text=...)
# before returning so sync
# hooks can answer callbacks
{"action": "rewrite", "data": "..."} # replace query.data, continue
{"action": "allow"} / None # normal dispatch
Result precedence: hooks are invoked in plugin-registration order; the
FIRST result whose action is skip/rewrite/allow wins. Later results for
the same callback are ignored.
Kwargs: query: telegram.CallbackQuery (live PTB object), data: str,
gateway: GatewayRunner | None (None outside a real gateway runner),
source: SessionSource.
Exception handling: ordinary Exception fails open — dispatch falls
through to normal prefix branches. Process-control exceptions
(KeyboardInterrupt, SystemExit, asyncio.CancelledError, other
BaseException subclasses) propagate unchanged.
Auth note: hook runs BEFORE per-branch authorization, so plugins
handling unauthorized clicks (e.g. retiring a legacy callback prefix
with a "reply by text" notice) can answer without triggering an
"Unauthorized" toast. Plugins that want auth must check source.user_id
themselves.
Rewrite warning: the model-picker branch (mp:/mm:/mb/mx/mg:) has NO
authorization check today; a plugin's rewrite that produces one of
those payloads will route the click into the model picker with
attacker-controlled bytes. Plugins SHOULD prefer skip with
plugin-owned handling for legacy migrations. A regression test pins
current behavior so future allow-list hardening is opt-in and explicit.
Logging: skip's data_prefix uses a bounded prefix extraction
(``data.split(":", 1)[0]`` only when a colon is present, else
``"<no-prefix>"``) so an opaque callback id without a delimiter is
never logged in full.
Implementation:
- VALID_HOOKS gains "pre_callback_dispatch" with full contract,
precedence, rewrite warning, and exception handling documented inline.
- BasePlatformAdapter gains ``self._gateway_ref`` + ``set_gateway_ref``
so callbacks fired from inside the adapter can pass a gateway handle.
May stay None in unit-test contexts.
- GatewayRunner calls ``adapter.set_gateway_ref(self)`` at both the
initial registration site and the reconnection site.
- TelegramAdapter._handle_callback_query invokes the hook at top,
processes skip/rewrite/allow, awaits ``query.answer(text=answer_text)``
on skip when payload contains answer_text (also fail-open), falls open
on any error.
Tests:
- tests/hermes_cli/test_plugins.py: VALID_HOOKS membership +
action-dict collection sanity (skip-shape).
- tests/gateway/test_pre_callback_dispatch.py (new): 14 cases covering
skip / rewrite / allow / None-return / exception / garbage-return /
kwargs-contract / runs-before-auth-check / skip-with-answer_text /
skip-answer-failure / skip-without-answer_text / first-actionable-wins /
rewrite-into-model-picker-permitted / skip-log-bounded-prefix.
sh940701
force-pushed
the
pr/pre-callback-dispatch-hook
branch
from
May 29, 2026 10:07
61f5bcc to
86bc4c1
Compare
Collaborator
Author
|
Thanks @alt-glitch for the quick triage — apologies for the duplicate. I missed #21471 in my pre-submit search (searched for Closing this PR in favor of #21471 (@ChaseFlorell's earlier implementation). I'll move the design notes from this PR over there as a comment — there are a few additive surface ideas ( PR #34541 (streaming stale-timer gate) is unrelated and I'll leave that one open. |
19 tasks
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.
What does this PR do?
Adds a new plugin hook
pre_callback_dispatchthat fires at the top ofTelegramAdapter._handle_callback_queryfor every inbound callback query (inline-keyboard button click), before any built-in prefix branch (model picker, gmail triage, exec approval, slash confirm, clarify, retirement notices, …) runs.Today there is no plugin hook for inbound callback queries.
pre_gateway_dispatchfires only onMessageEvents; inline-keyboard clicks hit_handle_callback_querydirectly and the hard-coded prefix branches handle them. There is no way for a plugin to:datafield before the built-in branches see it.This hook mirrors the action semantics of
pre_gateway_dispatch, so plugins that already speakskip/rewrite/allowget a consistent surface for the callback path.Action semantics
Result precedence: hooks are invoked in plugin-registration order; the FIRST result whose
actionisskip/rewrite/allowwins. Later results for the same callback are ignored. Plugins SHOULD returnNonefor cases they don't handle so they do not accidentally veto a later plugin's intent.Kwargs delivered:
query: telegram.CallbackQuery(live PTB object),data: str(current payload, possibly already rewritten by an earlier hook),gateway: GatewayRunner | None(None in unit-test contexts or pre-adapter-registration),source: SessionSource(chat_id may be""if the callback's chat can't be resolved; chat_type defaults to"dm").Exception handling: ordinary
Exceptionraised by any callback (or by the invocation layer) fails open — dispatch falls through to the normal prefix branches. Process-control exceptions (KeyboardInterrupt,SystemExit,asyncio.CancelledError, otherBaseExceptionsubclasses) propagate unchanged.Security note
The hook fires BEFORE the built-in prefix branches, which means a malicious or buggy plugin can intercept and answer ANY callback (including unauthenticated ones). This is documented in the new contributor docs. The behavior is symmetric with
pre_gateway_dispatchand intentional — plugins that need to act on legacy callbacks must run before the built-in retirement notices.Related Issue
Fixes #
(No related issue — opening this PR to start that conversation.)
Type of Change
Changes Made
gateway/platforms/telegram.py— invokepre_callback_dispatchat the top of_handle_callback_query; honorskip/rewrite/allow; whenanswer_textis present, awaitquery.answer(text=...)so synchronous hooks can answer.gateway/platforms/base.py—BasePlatformAdaptergains an optional_gateway_refback-reference +set_gateway_ref()setter so the callback path can pass a liveGatewayRunnerto hooks.gateway/run.py— wireadapter.set_gateway_ref(self)at both connect sites (initial bring-up + reconnection watcher).hermes_cli/plugins.py— register the new hook name; pass it through the plugin loader.tests/gateway/test_pre_callback_dispatch.py— new file, 14 unit tests covering: clean allow, skip without answer, skip withanswer_text, rewrite ofdata, hook exception fail-open, precedence (first-wins), invocation-layer exceptions,BaseExceptionpropagation, kwargs shape.tests/hermes_cli/test_plugins.py— extended for the new hook name.How to Test
pytest tests/gateway/test_pre_callback_dispatch.py tests/hermes_cli/test_plugins.py -q— 88 tests pass on this branch.pytest tests/ -q— passes (no regressions; result attached below if requested).{"action": "skip", "reason": "test", "answer_text": "hi"}for any callback data; install it; click any inline-keyboard button — Telegram shows "hi" instead of the built-in branch's response.Checklist
Code
feat(plugins):)pytest tests/ -qand the suite passestests/gateway/test_pre_callback_dispatch.py— 14 tests)Documentation & Housekeeping
docs/PLUGINS.mdif the maintainer wants it)cli-config.yaml.example— N/A (no new config keys)CONTRIBUTING.md/AGENTS.md— N/A (no architecture/workflow change beyond the new hook name)Notes for reviewer
pre_gateway_dispatchaction semantics (skip/rewrite/allow). If a different shape is preferred for callback queries (e.g.answer_callback_queryinstead ofanswer_text), happy to rename in this PR.