Skip to content

feat(plugins): add register_telegram_handler plugin API (mirrors Slack) - #61576

Closed
paoloantinori wants to merge 6 commits into
NousResearch:mainfrom
paoloantinori:feature/register-telegram-handler
Closed

paoloantinori wants to merge 6 commits into
NousResearch:mainfrom
paoloantinori:feature/register-telegram-handler

Conversation

@paoloantinori

Copy link
Copy Markdown
Contributor

What

Adds PluginContext.register_telegram_handler(handler, group=0) and the matching PluginManager.get_telegram_handlers(), wired into the PTB Application at TelegramAdapter.connect() time. This is the Telegram equivalent of the existing register_slack_action_handler / get_slack_action_handlers() pair — it fills an asymmetric gap (Slack exposed one, Telegram did not).

Why

Plugins currently have no official way to react to Telegram updates the built-in handlers don't cover — message reactions (message_reaction), edited messages, chat-member changes, custom commands. The only options today are monkey-patching the adapter or a sys.meta_path import hook, both of which couple to internals that change between releases.

register_slack_action_handler already solves this for Slack (SlackAdapter wires registered handlers into its slack_bolt.AsyncApp at connect). This PR gives Telegram the symmetric treatment so plugins register a telegram.ext.BaseHandler once and the adapter adds it to the app at connect — no monkey-patching, no coupling to the adapter's internal import/lifecycle.

How

  • PluginContext.register_telegram_handler(handler, group=0) — duck-types a BaseHandler via check_update/handle (so python-telegram-bot stays an optional dependency — no import in plugins.py), then queues (handler, group, plugin_name).
  • PluginManager._telegram_handlers + get_telegram_handlers() (returns a copy), cleared on forced re-discovery — parallel to _slack_action_handlers.
  • TelegramAdapter.connect() adds each registered handler via self._app.add_handler(handler, group=g). A single bad handler (or a plugin-layer failure) is logged and skipped; gateway connect is never blocked — same defensive shape as the Slack wiring.

Usage

from telegram import Update
from telegram.ext import TypeHandler

async def _on_reaction(update, context):
    if update.message_reaction:
        ...  # e.g. render a table image fallback for Telegram Web

def register(ctx):
    ctx.register_telegram_handler(TypeHandler(Update, _on_reaction), group=1)

Use group=1 (non-zero) to run alongside the built-in group-0 handlers without displacing them — documented in the docstring.

Tests

New tests/gateway/test_telegram_plugin_handlers.py (7 tests, no PTB dependency — duck-typed handler stand-in) covering validation, queuing, group, accessor-copy semantics, multi-plugin ordering, and the (handler, group, plugin_name) tuple contract the adapter loop unpacks.

Notes

  • The per-platform mirror (rather than a generic register_platform_handler) is deliberate and matches the existing house style: PTB add_handler(handler, group) and slack_bolt App.action(id)(fn) have incompatible signatures/semantics, so a generic API would need **kwargs + per-platform parsing with no caller saved. Only Slack and Telegram consume plugin handlers today.
  • Verified: connect() rebuilds self._app on each call, so handlers are registered exactly once per connect (no duplication on reconnect) — same property the built-in handler block already relies on.

Checklist

  • Mirrors existing register_slack_action_handler (PluginContext + PluginManager + adapter wiring)
  • Duck-typed validation keeps python-telegram-bot optional
  • connect-time wiring is defensive (bad handler / plugin-layer failure skipped, never blocks connect)
  • Tests added; /code-review high and /simplify run before opening

@alt-glitch alt-glitch added type/feature New feature or request comp/plugins Plugin system and bundled plugins platform/telegram Telegram bot adapter P3 Low — cosmetic, nice to have labels Jul 9, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Related: #59159 (earlier open PR, 2026-07-05) adds the same ctx.register_telegram_handler plugin API for Telegram PTB handlers, touching the same three files including the same test path. The two differ in registration mechanism: this PR passes a BaseHandler instance (register_telegram_handler(handler, group=0)), while #59159 passes a factory callable (application, adapter) invoked lazily at connect. Same feature, competing API shapes -- flagging for a maintainer to pick a canonical (#59159 is the earlier of the two).

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for adding a focused plugin extension and tests. I found one blocking PTB compatibility issue.

Problems

  • hermes_cli/plugins.py:1087-1088 requires handler.handle, but Hermes pins python-telegram-bot[webhooks]==22.6 in pyproject.toml:161; PTB v22.6's BaseHandler contract uses check_update() and handle_update(). Consequently, the documented TypeHandler(...) example is rejected before registration. The fake in tests/gateway/test_telegram_plugin_handlers.py masks this by aliasing handle = handle_update.
  • The existing MEMBER comment identifies #59159 as an earlier open implementation of the same public method using a lazy (application, adapter) factory. These are incompatible API shapes under the same name and need a canonical maintainer choice.

Suggested changes

  • Validate handle_update and add a regression case without a handle alias.
  • Consolidate with #59159's chosen API shape, then add a connect-path test that records Application.add_handler(..., group=...).

Automated hermes-sweeper review.

Comment thread hermes_cli/plugins.py Outdated
# that don't use Telegram (it is an optional dependency).
if not (
callable(getattr(handler, "check_update", None))
and callable(getattr(handler, "handle", None))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: Hermes pins python-telegram-bot 22.6, whose BaseHandler API exposes handle_update, not handle. This rejects real TypeHandler/MessageHandler instances, including the example above. Validate handle_update instead and make the test stand-in match that real interface.

@paoloantinori

Copy link
Copy Markdown
Contributor Author

Thanks @teknium1, @alt-glitch — good catches.

Blocking bug: fixed in ef4623f. The validator now keys on handle_update (PTB v22's BaseHandler has no handle attribute), and I dropped the handle = handle_update alias from the test fake so it mirrors a real PTB handler — the existing suite would have caught the original bug on its own. Added test_accepts_real_ptb_v22_contract as an explicit regression.

Connect-path test: added in 0c5d583. I extracted the plugin-handler wiring into _wire_plugin_handlers() (pure code motion, identical behavior) so it's testable without a full PTB connect(), then added TestTelegramAdapterPluginHandlerWiring, which exercises the connect path against a recording stand-in Application: each queued handler is add_handler(..., group=g)'d, an empty queue is a no-op, a plugin-manager load failure is isolated, and one rejected handler doesn't block the rest.

On consolidation with #59159: fair flag, and #59159 is earlier — happy to defer to maintainers on the canonical shape. For context on why I opened this separately: I modeled it on register_slack_action_handler (callback in, adapter binds at connect, plugin never touches the framework object), so the instance shape fell out of mirroring that existing surface. That said, the factory shape is genuinely stronger for handlers that need the live Application/adapter at connect time (a handler closing over the adapter to read state) and the lazy-import property is a real win for optional-dep environments. If maintainers prefer the factory, I'll rebase this onto #59159's shape rather than carry a duplicate.

Broader design note (not blocking): neither an instance nor a factory fully covers adapter-coupled plugin needs — e.g. a handler that fires on an inbound reaction and wants the content the bot sent for that message has to intercept outbound send/edit, which neither registration API provides. I'm scoping a small observer-only telegram:update / telegram:send / telegram:edit hook pair (extending the existing hook bus to the platform boundary) as a complementary PR; will link it once it's up.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages 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 11, 2026
@paoloantinori

Copy link
Copy Markdown
Contributor Author

Following up to drive the consolidation the earlier review flagged.

State: #59159 now reports mergeable: CONFLICTING and has been untouched for 6 days, so as things stand #61576 is the mergeable path for this surface.

Per the suggestion to align on #59159's factory shape, I'm happy to rebase this PR onto that API — plugins pass a (application, adapter) factory that the adapter invokes at connect — and carry it forward as the canonical implementation. That would let #59159 close as superseded. Two notes on what carries over:

  • This PR's connect-path test coverage stays (adapted to assert factories are invoked with (app, adapter) at connect).
  • The factory shape sidesteps the handle/handle_update validator issue altogether — plugins construct real PTB handlers themselves inside the factory, so PTB's own contract applies directly.

I'll start that rebase in the next day or so unless you'd rather revive #59159 directly — in which case I'll close this one. Either way, one word steers it.

@paoloantinori

Copy link
Copy Markdown
Contributor Author

Done — rebased onto #59159's factory shape in cec03dc7 per the consolidation suggestion.

register_telegram_handler(factory) now takes a (application, adapter) factory that the adapter invokes at connect(), right after the PTB Application is built and before the core handlers are added (so pattern-scoped plugin handlers take precedence; everything else falls through to core). Plugins construct their own PTB handlers inside the factory, so the old check_update/handle_update duck-type validation and the group param are gone — PTB's own contract applies at add_handler. The factory shape also sidesteps the earlier handle/handle_update validator bug entirely.

_wire_plugin_handlers isolates both plugin-manager load failure and per-factory exceptions, so one bad plugin can't break connect. Tests cover queuing/validation, the connect-path wiring (factories invoked with (app, adapter)), and isolation.

This supersedes #59159 — happy to see it closed (it conflicts with main and has been stale since Jul 5). Also fixed a doc bug carried over from #59159: the Notes advertised an adapter.bot attribute that doesn't exist (only self._bot); corrected to point at application.bot (PTB).

Ran code-review (high) and simplify before pushing.

Mirrors register_slack_action_handler so plugins can react to Telegram
updates the built-in handlers don't cover (message reactions, edited
messages, custom commands, chat-member changes, ...).

- PluginContext.register_telegram_handler(handler, group=0): duck-types a
  python-telegram-bot BaseHandler (check_update/handle — PTB stays an
  optional dependency) and queues a (handler, group, plugin_name) tuple.
- PluginManager._telegram_handlers + get_telegram_handlers() accessor, and
  clear() on forced re-discovery (parallels _slack_action_handlers).
- TelegramAdapter.connect() wires registered handlers into the PTB app at
  connect time, exactly mirroring how the Slack adapter consumes
  register_slack_action_handler. A single bad handler (or a plugin-layer
  failure) is logged and skipped; gateway connect is never blocked.

Motivating use case: a plugin that re-renders a pipe-table reply as a PNG
photo when a user reacts thumbs-down — Telegram Web can't render native
rich tables (sendRichMessage -> "not supported" card), so the plugin
renders an image fallback on demand. With this API that plugin registers
its reaction handler cleanly instead of monkey-patching the adapter.
register_telegram_handler duck-typed on check_update + `handle`, but the
pinned python-telegram-bot 22.6 BaseHandler contract is check_update() +
handle_update() (no `handle` attribute). The docstring's own
TypeHandler(...) example was rejected before registration; only the test
fake's `handle = handle_update` alias masked it.

- Validate handle_update instead of handle.
- Drop the masking alias so _FakeHandler mirrors a real PTB v22 handler.
- Add a regression test asserting no `handle` attr is required.

Addresses review feedback on NousResearch#61576.
Extract the plugin-handler wiring out of TelegramAdapter.connect() into
_wire_plugin_handlers() (pure code motion, identical behavior) so it can
be exercised without running a full PTB connect().

Add TestTelegramAdapterPluginHandlerWiring covering the connect path that
get_telegram_handlers() feeds:
- each queued handler is Application.add_handler()'d with its group
- an empty queue is a no-op
- a plugin-manager load failure is isolated (connect stays safe)
- one rejected handler does not block the rest from wiring

Addresses review on NousResearch#61576 (asked for a connect-path test recording
Application.add_handler(..., group=...)).
… shape

Rebases the plugin API onto NousResearch#59159's factory shape per review feedback
("Consolidate with NousResearch#59159's chosen API shape"):

- register_telegram_handler(factory): the adapter invokes each factory with
  (application, adapter) at connect time, right after the PTB Application is
  built and BEFORE the core handlers are added — so pattern-scoped plugin
  handlers take precedence while everything else falls through to core.
  Plugins construct their own PTB handlers inside the factory, so the
  check_update/handle_update duck-type validation and the group param are
  gone; PTB's own contract applies at add_handler.
- PluginManager: _telegram_handler_factories / get_telegram_handler_factories
  (was the (handler, group, plugin_name) tuple queue).
- TelegramAdapter._wire_plugin_handlers: invokes factory(self._app, self);
  load-failure and per-factory exceptions isolated so one bad plugin can't
  break connect. NousResearch#59159 can now close as superseded.

Tests rewritten for the factory shape: queuing/validation, the connect-path
wiring (factories invoked with (app, adapter)), and isolation (load failure +
a raising factory don't block others).

Also fixes a doc bug carried over from NousResearch#59159: the Notes bullet advertised an
adapter.bot attribute that doesn't exist (only self._bot); corrected to point
plugin authors at application.bot (PTB).

Ran code-review (high) + simplify before pushing.
… registrations

Post-rebase quality pass (/simplify, 4 reviewers):

- _wire_plugin_handlers(app) is now the first statement of
  _register_handlers instead of a call that had to be mirrored at every
  Application build site. The plugin/core/observer lockstep guarantee
  becomes structural: a future build site cannot forget the pairing.
- register_telegram_handler now goes through the ownership ledger
  (_track + _remove_identity) like its Slack sibling, returning a
  PluginRegistration: a reloaded plugin's old factory is removed on
  unload instead of being re-wired forever.
- Dropped the test file's degraded local telegram mock (weaker
  setdefault shape; the gateway conftest installs the comprehensive
  one at collection time), folded the None guard test into the
  non-callable one, and removed a no-op list comprehension in an
  assertion. Added a teardown test for the registration handle.
- Trimmed manager-side comments to entry shape; the precedence/
  isolation contract now lives in the two docstrings that own it.
Addresses the /code-review high findings on the rebased branch:

- Async factories are rejected at registration (inspect.iscoroutine-
  function) instead of silently no-opping: wiring runs from synchronous
  code, so an async def factory's coroutine could never be awaited.
  A sync-callable factory that still returns a coroutine gets it closed
  with an ERROR log at wire time, never a GC-time RuntimeWarning.
- A factory that adds group-0 handlers is flagged with a WARNING naming
  the plugin: group 0 is shared with the core handlers and PTB dispatches
  only the first match per group, so an unscoped handler silently
  swallows the core button/text flows.
- Tests pin the plugin-before-core order inside _register_handlers (a
  reorder would otherwise stay green while scoped plugin handlers lose
  first-match to the core set), plus coverage for the new guards.
- The factory contract is now documented in full: sync-only, idempotent
  across transient-init rebuilds, dispose dequeues future connects but
  does not unwire a running Application.
- New "React to Telegram updates" section in the plugin developer guide
  mirroring the Slack action-handler docs, including the pattern=
  scoping rule.
- Removed the em-dash from authored docstrings/comments.
@paoloantinori

Copy link
Copy Markdown
Contributor Author

Known limitation tracked in #87770: factories registered after the adapter has connected (late plugin load or force-discovery from tool paths) are queued but not wired onto the running Application until a reconnect. The Slack action-handler registry shares the same shape. Out of scope for this PR; the fix needs a registration-change notification from the plugin manager and should cover both adapters.

@teknium1

Copy link
Copy Markdown
Collaborator

Thanks @paoloantinori — this exact surface landed in #59217 (34393c3): ctx.register_telegram_handler exists as a back-compat alias over the generic ctx.register_platform_handler("telegram", factory), wired into the PTB Application at connect() before core handlers. Your factory-based handlers port directly. Closing as implemented on main.

@teknium1 teknium1 closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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 type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants