Skip to content

feat(telegram): config-driven callback→text routes for inline keyboards - #43949

Open
tjkang wants to merge 1 commit into
NousResearch:mainfrom
tjkang:callback-text-routes
Open

feat(telegram): config-driven callback→text routes for inline keyboards#43949
tjkang wants to merge 1 commit into
NousResearch:mainfrom
tjkang:callback-text-routes

Conversation

@tjkang

@tjkang tjkang commented Jun 11, 2026

Copy link
Copy Markdown

What does this PR do?

Adds config-driven callback→text routes to the Telegram adapter: platforms.telegram.extra.callback_text_routes maps inline-keyboard callback_data strings (exact match) to text that is injected into the conversation as if the tapping user had typed it.

Problem. Deployments that send inline-keyboard prompts outside the agent's own toolchain — e.g. a cron job or skill posting a morning check-in via the raw Bot API — have no way to route the button tap back into the normal conversation flow. The callback query arrives at _handle_callback_query, matches no built-in prefix, and is silently dropped. The only workaround today is a sticky reply_keyboard (poor UX) or patching the gateway.

Solution. A config-level escape hatch:

platforms:
  telegram:
    extra:
      callback_text_routes:
        "log:breakfast:default": "Yes, had the usual ✅"
        "log:breakfast:custom": "Had something different ✏️"

When an authorized user taps a button whose callback_data matches a route, the adapter:

  1. checks _is_callback_user_authorized (same gate as all built-in callbacks),
  2. acks the callback query,
  3. edits the prompt message to append the chosen answer and strip the keyboard (best-effort — a failed edit never blocks routing),
  4. builds a full-context MessageEvent (chat, user, thread, topic flags) via _build_message_event and dispatches it through handle_message, so session routing, skills, and logging treat it exactly like a typed message.

Why this approach / relation to open PRs. The send-side PRs (#42800, #42865, #29338, #38731, #28682) let the agent emit buttons and bundle their own receive paths. This PR is the orthogonal receive-side piece for buttons created outside send_message (skills, cron, external tooling), and is config-only:

  • no 64-byte payload constraint on the injected text (only the callback_data key is bounded),
  • display text is decoupled from callback data and can be updated in config without re-sending old keyboards,
  • the injected event carries full chat/user/thread context (vs a bare MessageEvent), so multi-topic and group-attribution routing keep working.

Safety. Routes are consulted as the final step of _handle_callback_query, only after every built-in prefix handler has declined the query — chain position alone guarantees config routes can never shadow built-in callbacks (mp:, gt:, ea:, update_prompt:, …), with no hand-maintained reserved-prefix list to drift. Parse-time validation drops non-string entries, empty text, and keys over Telegram's 64-byte callback_data limit with a warning. Injected text is attributed to the tapping user and passes the same authorization gate as typing — no new trust surface.

Related Issue

N/A — feature extracted from a long-running production deployment (daily companion bot) where it has been running as a local patch.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • gateway/platforms/telegram.py: _parse_callback_text_routes() (parsed once in __init__) + _handle_callback_text_route() invoked as the final step of _handle_callback_query
  • cli-config.yaml.example: documented extra.callback_text_routes with example
  • tests/gateway/test_telegram_callback_text_routes.py: 7 tests — routed tap (full context), keyboard strip + choice echo, unknown data fall-through, unauthorized rejection, edit-failure still routes, invalid config ignored, oversized/empty entries dropped

How to Test

  1. pytest tests/gateway/test_telegram_callback_text_routes.py -q → 8 passed
  2. pytest tests/ -q → full suite passes
  3. Manual: add a route to config.yaml, send a message with a matching inline_keyboard button via the Bot API, tap it → the mapped text is appended to the prompt, the keyboard disappears, and the agent responds as if the text was typed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstrings + config example
  • I've updated cli-config.yaml.example if I added/changed config keys
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure-Python, no platform-specific paths
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

platforms.telegram.extra.callback_text_routes maps inline-keyboard
callback_data strings (exact match) to text injected into the
conversation as if the tapping user had typed it. Lets deployments wire
buttons sent outside the agent's own toolchain (skills / cron jobs via
the raw Bot API) back into the normal conversation flow: authorization
check, callback ack, best-effort prompt edit (append choice + strip
keyboard), then a full-context MessageEvent through handle_message.

Routes are consulted as the final step of _handle_callback_query, after
every built-in prefix handler has declined the query — chain position
guarantees built-in callbacks always win. Parse-time validation drops
invalid entries and keys over Telegram's 64-byte callback_data limit.
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter P3 Low — cosmetic, nice to have labels Jun 15, 2026

@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 the focused, config-driven route design. The problem remains present on current main: the active handler returns for unrecognized callback data at plugins/platforms/telegram/adapter.py:5637-5639.

Problems

  • The PR modifies gateway/platforms/telegram.py, but current main no longer tracks that adapter. Commit 476d8d9cc migrated Telegram into the plugin surface, and polling now registers plugins/platforms/telegram/adapter.py::_handle_callback_query at plugins/platforms/telegram/adapter.py:3202-3203. The implementation and its tests therefore do not affect the runtime Telegram adapter.

Suggested changes

  • Port the parser, route handler, initialization, and final callback fallback into plugins/platforms/telegram/adapter.py; port the test import to plugins.platforms.telegram.adapter as current Telegram tests do.
  • Add the config example to website/docs/user-guide/messaging/telegram.md, which already documents platforms.telegram.extra settings.

Automated hermes-sweeper review.

self._mention_patterns = self._compile_mention_patterns()
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
# Config-driven callback→text routes (extra.callback_text_routes):

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.

Blocking: current main no longer contains gateway/platforms/telegram.py; Telegram now runs from plugins/platforms/telegram/adapter.py (migration 476d8d9cc). Please port this initialization and the associated handler changes to the active plugin adapter.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state 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 sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
@Space-Hermes

Space-Hermes commented Aug 3, 2026

Copy link
Copy Markdown

Independent validation + conflict diagnosis

I ran this exact design in production today (same config-driven extra.callback_text_routes approach, adapted to the current tree) and can confirm the mechanics work end-to-end:

  • Sent a message with an inline keyboard via the raw Bot API to a Telegram channel topic
  • Tapping the button produced a callback that the adapter routed through the config map and re-injected as a full-context MessageEvent — session routing, attribution, and downstream agent processing all behaved exactly as if the user had typed the mapped text
  • Authorization gate, keyboard strip on success, and edit-failure tolerance all held up

On the merge conflict

The PR is currently conflicting with main. The cause: the Telegram adapter moved from gateway/platforms/telegram.py to plugins/platforms/telegram/adapter.py (bundled-plugin migration, commit 560010547 and friends), so the hunks no longer apply at the old paths. The current tree has the same anchor points (_coerce_bool_extra, _handle_callback_query, the update_prompt: fall-through), so the port is mechanical — the diff I ran locally was nearly identical, just relocated into plugins/platforms/telegram/adapter.py with the tests moved to tests/gateway/test_telegram_callback_text_routes.py (same import path, from plugins.platforms.telegram.adapter import TelegramAdapter).

Suggested extension: prefix routes (applicable beyond our use case)

While validating, I extended the design with one small, generally useful addition: prefix route keys (keys ending in *). An exact-match map alone requires one config entry per distinct button payload; with prefix support, a single entry like "ca:approve:*": "APPROVE " handles an unbounded set of dynamic payloads — e.g. ca:approve:t_abc injects APPROVE t_abc. Exact matches still win over prefix routes, and the 64-byte callback_data limit check still applies to the full key.

Use cases this unlocks beyond my own:

  • Approval/decision buttons bound to arbitrary entity IDs (cards, orders, tickets) without editing config per entity
  • Poll/quiz buttons with dynamic option suffixes
  • Any cron/skill-driven workflow that generates buttons programmatically with variable payloads

Happy to rebase this PR onto current main (with or without the prefix extension) if that is useful — just say the word. This feature would unblock a class of cron/skill-driven button workflows that currently require patching the gateway.

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/telegram Telegram bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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 sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants