Skip to content

feat(telegram): tool-side inline-query dispatch - #52683

Closed
elphamale wants to merge 7 commits into
NousResearch:mainfrom
elphamale:feat/telegram-inline-toolside
Closed

feat(telegram): tool-side inline-query dispatch#52683
elphamale wants to merge 7 commits into
NousResearch:mainfrom
elphamale:feat/telegram-inline-toolside

Conversation

@elphamale

@elphamale elphamale commented Jun 25, 2026

Copy link
Copy Markdown

Summary

Adds Telegram inline-query (@bot …) support as a thin adapter dispatcher over a tool-side router. The Telegram adapter stays a ~15-line dispatcher; all inline logic — query→tool matching, executor lookup, the response deadline, and executor discovery — lives in the router, and concrete executors are user-space.

Replaces the earlier inline-mode draft #50884.

Design

  • gateway/platforms/telegram_inline_router.py (new) — the dispatch surface:
    • InlineToolRegistry loads inline_tools.yaml and matches a query to a tool (url / prefix / search patterns, priority-ordered).
    • InlineExecutor ABC — the user-space contract: execute(user_id, query) -> [InlineQueryResult].
    • TelegramInlineRouterregister_executor, discover_executors() (loads modules from inline_executors/), and dispatch() which enforces the answerInlineQuery RESPONSE_DEADLINE (6.5 s) router-side.
  • plugins/platforms/telegram/adapter.py — ~15 lines: register an InlineQueryHandler in connect(), and _handle_inline_query forwards to the router. inline.enabled: false in the platform's config.extra silences it without unregistering the handler. No new platform, no config.py registration.

Scope: dispatch only — execution phase to follow

This PR ships the dispatch surface: query→tool matching, executor registration/discovery, and the response-deadline contract. It does not yet include an execution phase — the framework bundles no concrete executors, and dispatch() returns empty when no user-space executor is registered for a matched tool. Concrete executors (and any framework-level execution helpers) are a deliberate follow-up; for now they live entirely in user-space (inline_executors/, each module defining register(router)).

For an illustrative set of executors written against this contract — media lookup, repost, an optional classifier layer — see hermes-telegram-inline-tools. Example only; not bundled, required, or endorsed by this PR.

Why now

This lands ahead of the rest of the guest-mode work on purpose. Inline-query code had accreted inside plugins/platforms/telegram/adapter.py, and that bloat is the main source of the rebase conflicts across the guest-mode PR stack. Extracting inline mode into a thin dispatcher + router removes ~120 lines from the adapter, giving the guest-mode chain a clean base to rebase onto — so this is a prerequisite for landing guest mode cleanly, not just a standalone feature.

Tests

  • tests/gateway/test_telegram_inline_router.py (7): registry matching (url / prefix+priority / disabled / missing-file), dispatch routing (no-match, unregistered executor), and executor discovery.
  • tests/gateway/test_telegram_inline_query.py (5): the adapter's inline.enabled gate (disabled / enabled / default), the full dispatch→executor→answer() path with a stand-in executor, and the response-deadline cancel path.

The framework still bundles no executors (execution phase deferred); the end-to-end test registers a stand-in executor to exercise the wiring. Existing platform suite (162) unaffected; sources compile.

Test plan

  • Unit suite green (router dispatch/discovery; adapter inline.enabled gate, e2e wiring with a stand-in executor, deadline cancel)
  • inline.enabled: false → inline silently ignored (unit-tested)
  • dispatch → registered executor → results reach answer() (unit-tested with a stand-in executor)
  • live: with real user-space executors and inline mode enabled in BotFather, @bot <query> returns results in the client

Update: fixed the lazy-install rebind bug, implemented prefix/handles, documented inline.enabled nesting (addresses hermes-sweeper review)

Also rebased onto origin/main (660 commits behind).

Blocking: InlineQueryHandler never rebound after lazy install. check_telegram_requirements()'s rebind path (used when python-telegram-bot isn't installed at first import and gets lazy-installed on demand) was missing InlineQueryHandler from its global declaration, its re-import, and its rebind assignment — the other handler classes (CommandHandler, CallbackQueryHandler, etc.) were all there, this one just got missed. After a successful lazy install, InlineQueryHandler stayed pinned at the module-load-failure fallback (Any), so connect()'s InlineQueryHandler(self._handle_inline_query) call raised (Any(...) isn't callable) and was silently swallowed by that call site's own broad except Exception — logged as "inline-query dispatch not enabled" with no other symptom. Fixed all three spots and added a regression test exercising the real rebind path.

prefix / handles — implemented rather than removed. Both are useful as documented (a literal-prefix shorthand and a per-tool bot-username allowlist for shared registries), so implemented them in InlineToolRegistry.match() instead of stripping them from the schema: prefix folds into the tool's effective matcher list (escaped, not a raw regex), and handles filters candidate tools by the router's bot_username before ranking. TelegramInlineRouter.dispatch() now threads its own bot_username into match() so handles is actually enforced during real dispatch, not just when calling match() directly by hand. 8 new tests cover both, including case-insensitive @ handling for handles and that prefix is treated as a literal, not a regex wildcard.

inline.enabled nesting — documented and tested. PlatformConfig.from_dict() only ever populates .extra from the YAML extra: block — inline isn't one of the shared/bridged top-level platform keys (allow_from, require_mention, etc. — see the shared-key loop in load_gateway_config()), so telegram: inline: {...} written as a sibling of enabled/token is silently dropped; it must be telegram: extra: inline: {...}. Documented exactly this on _handle_inline_query's docstring, with the working YAML alongside the broken form, and added two load_gateway_config() tests: one proving the extra: nesting round-trips correctly, one proving the flat/top-level form is silently ignored (so a regression here fails loudly instead of quietly breaking someone's config).

Confirmed the usual 9 telegram test-order-pollution failures elsewhere in the suite are unrelated (same ones from earlier PRs this session), and that test_inline_end_to_end_with_stub_executor (pre-existing) plus the new test_dispatch_threads_bot_username_to_registry_handles_filter fall victim to that same pollution only when run together with the full test_config.py suite — confirmed via git stash that the pre-existing test already failed this way before any of these changes. All inline-specific test files (20 tests) pass cleanly in isolation.

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/telegram Telegram bot adapter P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jun 25, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: supersedes the closed #50884 (and its closed predecessor #50880) — same author, same goal (Telegram inline-query mode), but this revision keeps the adapter thin and pushes inline logic to a tool-side router instead of a second Platform.TELEGRAM_INLINE adapter.

@elphamale elphamale changed the title feat(telegram): tool-side inline-query dispatch (supersedes #50884) feat(telegram): tool-side inline-query dispatch Jun 25, 2026
@elphamale
elphamale force-pushed the feat/telegram-inline-toolside branch 2 times, most recently from 9099af2 to fb99231 Compare July 9, 2026 09:00

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for keeping the Telegram adapter thin and carrying the router tests with the feature.

Problems

  • plugins/platforms/telegram/adapter.py:253-296 lazily re-imports and rebinds the existing PTB globals, but the new InlineQueryHandler is absent from that path. After a first import without PTB, the PR leaves it as Any; the new registration in the PR's connect() block then fails and the broad exception silently disables inline dispatch. Please rebind it and add coverage for the lazy-install path.
  • gateway/platforms/telegram_inline_router.py:18-19 documents tool-level prefix and handles, but InlineToolRegistry.match() in the PR only reads tool["match"]. Either implement those declared fields or remove them from the public schema.
  • The new gate tests build PlatformConfig.extra directly. PlatformConfig.from_dict() only preserves data["extra"] (gateway/config.py:500-539), so add a config-loader test and document the exact YAML nesting required for inline.enabled.

This is an automated hermes-sweeper review.

Comment thread plugins/platforms/telegram/adapter.py Outdated
@@ -162,6 +162,7 @@ async def _shutdown_abandoned_app(app) -> None:
Application,
CommandHandler,
CallbackQueryHandler,
InlineQueryHandler,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_telegram_requirements() has a lazy-install recovery path that rebinds the existing handler globals, but it will not rebind this new one. Include InlineQueryHandler as _IQH in that import and assign it globally there; otherwise an adapter imported before PTB is installed leaves this as Any and the new connect-time registration is caught and silently disabled.

prefix: "!" # shorthand: routes queries starting with "!" to this tool
handles: [mybotname] # optional list of bot usernames; omit to match all bots
match:
- pattern: "https?://example\\.com/"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public schema says tool-level prefix and handles affect routing, but match() only evaluates entries under tool["match"]. Please implement those fields (including bot filtering) or remove them from this contract until they are supported.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
@elphamale
elphamale force-pushed the feat/telegram-inline-toolside branch from fb99231 to 47a6b08 Compare July 15, 2026 18:26
@elphamale

Copy link
Copy Markdown
Author

Fixed all three, pushed 47a6b08a7 (also rebased onto origin/main, 660 commits behind).

InlineQueryHandler rebind: confirmed exactly as described — missing from the global declaration, the re-import, and the rebind assignment in check_telegram_requirements(). All three other handler types were correctly wired; this one just got missed. Added a regression test that forces TELEGRAM_AVAILABLE=False/InlineQueryHandler=Any (simulating the pre-install state), monkeypatches lazy_deps.ensure to a no-op, and confirms the rebind actually happens.

prefix/handles: went with implementing them rather than trimming the schema — both are genuinely useful (literal-prefix shorthand, per-tool bot-username allowlist for shared registries) and were already fully specified in the docstring. prefix now folds into the tool's effective matcher list (escaped, so a literal . doesn't act as a regex wildcard), and handles filters by the router's bot_username — threaded from TelegramInlineRouter.dispatch() into match() so it's enforced during real dispatch, not just when calling match() by hand. 8 new tests, including case-insensitive @-stripped handles matching.

inline.enabled nesting: confirmed the gap — inline isn't in the shared-key bridge list, so it's extra:-only. Documented the exact required YAML on _handle_inline_query and added load_gateway_config() tests for both the correct nesting and the (silently-dropped) flat form.

elphamale and others added 6 commits July 16, 2026 09:49
Adds Telegram inline-query (@bot) support as a thin adapter dispatcher over
a tool-side router, rather than a parallel platform/adapter.

- gateway/platforms/telegram_inline_router.py: yaml-driven registry
  (inline_tools.yaml) + InlineExecutor ABC + TelegramInlineRouter. The
  router owns query->tool matching, executor lookup, the answerInlineQuery
  response deadline, and discovery of user-space executors from
  inline_executors/. The framework ships the dispatch surface only;
  concrete executors are user-space.
- plugins/platforms/telegram/adapter.py: ~15-line dispatch -
  _handle_inline_query forwards to the router; registers an
  InlineQueryHandler in connect(); inline.enabled in config.extra gates it.

No Platform.TELEGRAM_INLINE / second adapter / config.py platform
registration -- inline queries are handled by the main adapter.

Tests: tests/gateway/test_telegram_inline_router.py (9) -- registry
matching, dispatch paths, deadline enforcement, executor discovery.

Supersedes NousResearch#50884 (the parallel-platform approach).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the executor-execution tests (running a stub through dispatch and the
deadline path) — this PR ships dispatch only; the execution phase (concrete
executors producing results) is a later addition. Remaining tests cover
registry matching, routing decisions, and executor discovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds tests/gateway/test_telegram_inline_query.py for _handle_inline_query:
disabled -> no dispatch and no answer; enabled -> dispatch + results
passthrough; unconfigured -> enabled by default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ne tests

Move the answerInlineQuery RESPONSE_DEADLINE back to the adapter
(_handle_inline_query wraps dispatch in wait_for) instead of enforcing it
inside router.dispatch. The adapter-side deadline holds even when an
executor layer replaces the router's dispatch (e.g. a classifier), which an
in-dispatch deadline would silently lose.

Adds adapter tests: the full dispatch -> executor -> answer path with a
stand-in executor, and the deadline-cancel path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t prefix/handles; document inline.enabled nesting

hermes-sweeper review of this PR raised three points:

1. check_telegram_requirements()'s lazy-install rebind path was missing
   InlineQueryHandler from its `global` declaration, its re-import from
   telegram.ext, and its rebind assignment. After a first import without
   python-telegram-bot installed, InlineQueryHandler stayed pinned at the
   module-load-failure fallback (`Any`) even once TELEGRAM_AVAILABLE
   flipped True post-install. connect()'s
   InlineQueryHandler(self._handle_inline_query) call then raised
   (Any(...) isn't callable), caught by that call site's broad
   `except Exception` and logged as "inline-query dispatch not enabled"
   with no other symptom. Added InlineQueryHandler to all three places
   and a regression test that exercises the actual rebind path.

2. gateway/platforms/telegram_inline_router.py's docstring documented
   `prefix` (shorthand for a single type=prefix matcher) and `handles`
   (per-tool bot-username allowlist) as registry fields, but
   InlineToolRegistry.match() only ever read `match`. Implemented both:
   `prefix` folds into the tool's effective matcher list (escaped, not
   treated as a regex), and `handles` filters candidate tools by the
   router's bot_username before ranking — TelegramInlineRouter.dispatch()
   now threads its own bot_username into match() so the filter is
   actually enforced during real dispatch, not just when calling
   match() directly.

3. The gate tests built PlatformConfig.extra directly, masking that
   PlatformConfig.from_dict() only ever populates .extra from the YAML
   extra: block — inline is not one of the shared/bridged top-level
   platform keys (allow_from, require_mention, etc.), so
   `telegram: inline: {...}` written as a sibling of enabled/token is
   silently dropped; it must be `telegram: extra: inline: {...}`.
   Documented this exactly on _handle_inline_query's docstring and added
   two load_gateway_config() tests: one proving the extra: nesting works,
   one proving the flat/top-level form doesn't.

Also rebased this branch onto origin/main (660 commits behind).

Confirmed the usual 9 telegram test-order-pollution failures elsewhere in
the suite are unrelated (same ones from earlier PRs this session), and
that two of the new/existing inline tests (test_inline_end_to_end_with_
stub_executor, test_dispatch_threads_bot_username_to_registry_handles_
filter) fall victim to that same pre-existing pollution only when run
together with the full test_config.py suite — verified via git stash
that test_inline_end_to_end_with_stub_executor already failed this way
before any of these changes. All inline-specific test files pass cleanly
in isolation (20 tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
check-windows-footguns.py flagged open() without encoding= in
InlineToolRegistry._load() -- platform-default encoding (cp1252/mbcs on
Windows) can mojibake a registry file authored on POSIX.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
@elphamale
elphamale force-pushed the feat/telegram-inline-toolside branch from 47a6b08 to f8090b6 Compare July 16, 2026 06:50
…lability

check_telegram_requirements() bundled InlineQueryHandler into the same
`from telegram.ext import (...)` statement as CallbackQueryHandler,
MessageHandler, ContextTypes, and filters (both at the module-level import
and the lazy-install rebind path). Any environment whose telegram.ext
doesn't define InlineQueryHandler -- an older PTB release predating inline
mode, or (as several pre-existing test files' minimal telegram.ext doubles
turned out to be) a mock that only stubs the handlers it exercises --
raised ImportError for the WHOLE statement, leaving ParseMode, filters,
etc. pinned at their None/Any import-failure fallbacks. That broke real
Telegram sends entirely ('NoneType' object has no attribute 'MARKDOWN_V2'),
not just inline-query dispatch -- surfaced as 7 failures in
tests/gateway/test_telegram_thread_fallback.py once this branch rebased
onto current main and picked up whatever else runs before it in a shared
test process.

Fix: give InlineQueryHandler its own try/except in both import sites,
mirroring the existing LinkPreviewOptions optional-import pattern already
a few lines above each -- its absence now only disables the inline-query
bonus feature, exactly as intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
@elphamale

Copy link
Copy Markdown
Author

Rebased onto current main (289 commits, clean) as part of today's PR-conflict drift audit.

Also fixed a real bug found while testing the rebase: check_telegram_requirements() bundled InlineQueryHandler into the same from telegram.ext import (...) statement as CallbackQueryHandler/MessageHandler/ContextTypes/filters (both at module-import time and in the lazy-install rebind path). Any environment whose telegram.ext doesn't define InlineQueryHandler — an older PTB release predating inline mode, or several pre-existing test files' minimal telegram.ext doubles that only stub the handlers they exercise — raised ImportError for the whole statement, leaving ParseMode/filters/etc. pinned at their None/Any fallbacks. That broke real Telegram sends entirely ('NoneType' object has no attribute 'MARKDOWN_V2'), not just inline dispatch — surfaced as 7 failures in test_telegram_thread_fallback.py once this branch picked up 289 commits of drift.

Fixed by giving InlineQueryHandler its own try/except in both import sites, mirroring the existing LinkPreviewOptions optional-import pattern already a few lines above each. Added a regression test (test_missing_inline_query_handler_does_not_break_availability) proving check_telegram_requirements() still succeeds and rebinds core symbols when only InlineQueryHandler is absent.

Also fixed a Windows footgun (open() without encoding= in the inline registry loader).

Full tests/gateway/ sweep via scripts/run_tests_parallel.py: 477 files, 9342 tests, 0 failures.

@elphamale

elphamale commented Jul 27, 2026

Copy link
Copy Markdown
Author

After some experiments, I've decided inline mode isn't conducive to interacting with an agent at this stage, and isn't useful enough to justify keeping ше on the bot: you can't get a response from the agent fast enough given the short response deadline, and the kinds of tools that could realistically run within that window aren't useful enough to be worth it. It also interferes with much more useful guest mode (PR #56476) in practice: on most clients, typing the agent's @handle switches the composer into inline-query mode, which then blocks you from actually sending the message that would trigger guest mode. It doesn't block the interaction completely because the bot can still be called in guest mode by typing @handle after the request, but in my opinion it creates enough friction to be annoying. Not worth it for what inline mode was delivering - closing this out.

@elphamale elphamale closed this Jul 27, 2026
elphamale pushed a commit to elphamale/hermes-agent that referenced this pull request Aug 13, 2026
check-windows-footguns.py flagged open() without encoding= -- platform-default
encoding (cp1252/mbcs on Windows) can mojibake this file's contents.

Rebased onto the fixed feat/telegram-inline-toolside (NousResearch#52683) as part of
today's PR-conflict drift audit -- both PRs insert a handler registration
at the same point in TelegramAdapter.connect(); purely textual, kept both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CvMiTppKDazXfakuoLgQK
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 comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/telegram Telegram bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants