TelegramEventObserver
in package
Routing observer for a single Telegram update type — port of `aiogram.dispatcher.event.telegram.TelegramEventObserver`.
Each Router owns one of these per Bot API event key (message,
callback_query, inline_query, …). It tracks two collections:
- Handlers — registered callbacks, each wrapped in a
HandlerObjectthat carries its filter pipeline and flags. Dispatched in registration order; the first handler whose filters all accept claims the event and its return value is the trigger's result. - Global filters — predicates applied before any handler dispatch.
A single rejection (
false/null/[]) short-circuits the entire chain toRejectedSentinel— no handlers are tried. Used by scenes to scope every handler on the observer to the current scene state.
The register() signature is a deliberate deviation from upstream's
Python form register(callback, *filters, flags=None). PHP cannot place
a named optional parameter after a variadic, and we want the friendly
call-site register($cb, [$f1, $f2], ['admin_only' => true]) to work
without quirky null-padding. Filters become a list<Closure> and
flags become an array<string, mixed>; both are optional and positional.
__invoke() keeps the decorator-factory ergonomics: passed a callback
it registers eagerly, otherwise it returns a registration closure. PHP
lacks Python decorator syntax, but this two-shape __invoke lets us
support both $obs($cb, flags: [...]) and the factory form
$register = $obs(filters: [...]); $register($cb);.
The flags-merge semantics match upstream flags = {**extract_flags_from_object(cb), **flags}:
attribute / WeakMap-attached flags are merged in first, then the manual
$flags argument overlays them last (manual wins on key collision).
Middleware integration — the observer owns two MiddlewareManager
collections that compose around the dispatch primitive:
$outerMiddlewarewraps the entire observer (global filter chain plus the handler iteration) AND, when the observer is owned by aRouter, the sub-router walk too — wrapping lives inRouter::propagateEvent(Fix I2). A bare$observer->trigger()call skips the outer wrap (matches upstreamTelegramEventObserver.trigger).$innerMiddlewarewraps each individual handler invocation (HandlerObject::call). This is where per-handler concerns like throttling, auth gates, and cache hits attach.
The split mirrors upstream's outer_middlewares / middlewares lists on
TelegramEventObserver at aiogram/dispatcher/event/telegram.py:23-31.
The two managers are independent — registering on one does NOT register
on the other — and the outer chain has access to the kwargs the global
filters would see, while the inner chain runs only after the per-handler
filter check accepts.
Table of Contents
Properties
- $eventName : string
- $filters : array<int, FilterObject>
- Global filters applied before any handler dispatch — every filter must accept (truthy result, i.e. `!$result === false`) for the handler chain to be considered. Append via `filter()`.
- $handlers : array<int, HandlerObject>
- Registered handlers in declaration order. Exposed read-only so the dispatcher / introspection code can iterate; mutation goes through `register()` / `clear()`.
- $innerMiddleware : MiddlewareManager
- Inner middleware chain — wraps each individual handler invocation.
- $outerMiddleware : MiddlewareManager
- Outer middleware chain — wraps the entire observer (global filters + handler iteration) AND, when this observer is owned by a `Router`, the `Router::propagateEvent` sub-router walk (Fix I2). Registered via `outerMiddleware()`. Note: the chain is applied by `Router::propagateEvent`, NOT by `trigger()` — `trigger()` is the raw dispatch primitive (upstream parity).
- $router : Router|null
- Back-reference to the owning `Router`. `null` when the observer is constructed standalone (legitimate for unit tests that don't need chain-head inheritance). Populated by `Router::__construct` for every observer in the schema-derived map so `resolveMiddlewares()` can walk the ancestor chain via `Router::$parentRouter`.
Methods
- __construct() : mixed
- __invoke() : callable(Closure): Closure
- Dual-shape decorator entrypoint mirroring upstream's `__call__`.
- clear() : void
- Drop every registered handler and global filter. Intended for test isolation and router rebuilds — production routing never calls this.
- filter() : void
- Append global filters that gate every handler on this observer.
- innerMiddleware() : BaseMiddleware
- Append a middleware to the **inner** chain (wraps each handler call).
- outerMiddleware() : BaseMiddleware
- Append a middleware to the **outer** chain (wraps the whole observer).
- register() : Closure
- Register a handler with optional per-handler filters and flag metadata.
- resolveMiddlewares() : array<int, BaseMiddleware>
- Resolve every inner-middleware instance that should wrap each handler invocation on this observer — self's own plus every ancestor router's matching observer's inner middleware. Mirrors upstream's `_resolve_middlewares` (`telegram.py:49-56`):
- trigger() : mixed
- Raw dispatch entry: runs global filters then iterates handlers (with per-handler filter check and inner-middleware wrapping). Does NOT apply outer middleware — that responsibility lives in `Router::propagateEvent`, which wraps the full local-observer + sub- router dispatch with `outerMiddleware->wrap()` once per propagation.
- triggerCore() : mixed
- The actual dispatch primitive (post-outer-middleware): runs global filters then iterates handlers with per-handler filter check and inner-middleware wrapping.
Properties
$eventName read-only
public
string
$eventName
$filters
Global filters applied before any handler dispatch — every filter must accept (truthy result, i.e. `!$result === false`) for the handler chain to be considered. Append via `filter()`.
public private(set)
array<int, FilterObject>
$filters
= []
$handlers
Registered handlers in declaration order. Exposed read-only so the dispatcher / introspection code can iterate; mutation goes through `register()` / `clear()`.
public private(set)
array<int, HandlerObject>
$handlers
= []
$innerMiddleware read-only
Inner middleware chain — wraps each individual handler invocation.
public
MiddlewareManager
$innerMiddleware
Registered via innerMiddleware(). Per-handler concerns (throttling,
cache lookup, auth gates) attach here so the rest of the observer
(global filter chain, per-handler filter pipeline) runs first.
$outerMiddleware read-only
Outer middleware chain — wraps the entire observer (global filters + handler iteration) AND, when this observer is owned by a `Router`, the `Router::propagateEvent` sub-router walk (Fix I2). Registered via `outerMiddleware()`. Note: the chain is applied by `Router::propagateEvent`, NOT by `trigger()` — `trigger()` is the raw dispatch primitive (upstream parity).
public
MiddlewareManager
$outerMiddleware
$router read-only
Back-reference to the owning `Router`. `null` when the observer is constructed standalone (legitimate for unit tests that don't need chain-head inheritance). Populated by `Router::__construct` for every observer in the schema-derived map so `resolveMiddlewares()` can walk the ancestor chain via `Router::$parentRouter`.
public
Router|null
$router
Mirrors upstream's TelegramEventObserver(router=self, event_name=...)
constructor (telegram.py:26-28). The reference is ?Router not
Router because the observer is sometimes built before its router
(PHP property initializers run before the __construct body, but
the observer's own ctor needs the router reference up-front), and
null is the contract-safe default for unit-test instantiation.
Methods
__construct()
public
__construct(string $eventName[, Router|null $router = null ]) : mixed
Parameters
- $eventName : string
-
Wire-level Bot API key this observer routes for (
message,callback_query, …). The dispatcher uses this when injecting theevent_namekwarg into middleware data. - $router : Router|null = null
-
Back-reference to the owning router. Populated by
Router::__construct; leftnullfor tests that drive the observer in isolation.
__invoke()
Dual-shape decorator entrypoint mirroring upstream's `__call__`.
public
__invoke([Closure|null $callback = null ][, array<int, Closure> $filters = [] ][, array<string, mixed> $flags = [] ]) : callable(Closure): Closure
- Eager form:
$observer($cb, filters: [...], flags: [...])registers immediately and returns the original callback. - Factory form:
$register = $observer(filters: [...], flags: [...]);returns a registration closure to be invoked later with the callback.
The factory form is the PHP idiom for the Python decorator
@router.message(F::text->equals('hi'), flags=[...]) — we can't apply
a decorator with the @ syntax, but we can produce the same closure
factory.
Parameters
- $callback : Closure|null = null
- $filters : array<int, Closure> = []
- $flags : array<string, mixed> = []
Return values
callable(Closure): Closure —In factory mode, a closure that accepts the callback and registers it. In eager mode, the original callback (already registered).
clear()
Drop every registered handler and global filter. Intended for test isolation and router rebuilds — production routing never calls this.
public
clear() : void
filter()
Append global filters that gate every handler on this observer.
public
filter(Closure ...$filters) : void
Each closure is wrapped in a FilterObject (reflection-cached kwarg
binding). Multiple filter() calls accumulate — the filters run in
insertion order during trigger(), and the first one to reject (any
falsy return) short-circuits the dispatch to RejectedSentinel.
Parameters
- $filters : Closure
innerMiddleware()
Append a middleware to the **inner** chain (wraps each handler call).
public
innerMiddleware(BaseMiddleware $middleware) : BaseMiddleware
Mirror of outerMiddleware() for the per-handler chain.
Parameters
- $middleware : BaseMiddleware
Return values
BaseMiddlewareouterMiddleware()
Append a middleware to the **outer** chain (wraps the whole observer).
public
outerMiddleware(BaseMiddleware $middleware) : BaseMiddleware
Thin wrapper around $outerMiddleware->register() that exists so
Dispatcher's setup code reads $observer->outerMiddleware(new …) —
matching upstream's observer.outer_middleware(...) call site.
Parameters
- $middleware : BaseMiddleware
Return values
BaseMiddlewareregister()
Register a handler with optional per-handler filters and flag metadata.
public
register(Closure $callback[, array<int, callable> $filters = [] ][, array<string, mixed> $flags = [] ]) : Closure
The Python upstream uses register(callback, *filters, flags=None);
PHP cannot place named-optional parameters after a variadic, so we
flatten filters into a list<Closure> and flags into a positional
associative array. Both default to empty.
Flag-merge order matches upstream:
- Read
#[Flag]attributes and imperativeFlagDecorator::attach()attachments from the callback (Flags::extractFlags). - Overlay the manual
$flagsargument on top — manual wins on key collision ({**attribute, **manual}semantics).
Returns the original callback unchanged so registration sites can keep
a reference for re-use (matches EventObserver::register() ergonomics).
Filter entries may be any callable — bare Closures, first-class
callable expressions (fn() => true), or invokable objects such as
Filter subclasses (new Command('start'), new StateFilter(...),
a user Filter instance). Invokable objects are wrapped with a
Closure::fromCallable adapter so the FilterObject invariant
(Closure $callback) stays intact. Mirrors aiogram's
aiogram.dispatcher.event.handler.FilterObject which accepts any
Callable[..., Any].
Parameters
- $callback : Closure
- $filters : array<int, callable> = []
-
Per-handler filter pipeline. An empty list means "always accept" — fall-through to the handler itself.
- $flags : array<string, mixed> = []
-
Manual flag overrides. Merged with attribute / WeakMap-attached flags via
Flags::extractFlags.
Return values
ClosureresolveMiddlewares()
Resolve every inner-middleware instance that should wrap each handler invocation on this observer — self's own plus every ancestor router's matching observer's inner middleware. Mirrors upstream's `_resolve_middlewares` (`telegram.py:49-56`):
public
resolveMiddlewares() : array<int, BaseMiddleware>
for router in reversed(tuple(self.router.chain_head)): observer = router.observers.get(self.event_name) if observer: middlewares.extend(observer.middleware) return middlewares
chain_head walks self → parent → root; reversed then yields
root → … → self, so the outermost link of the composed chain is the
root router's middleware. The PHP port iterates the same order: we
walk from $this->router upward, push each ancestor's matching
observer's middleware onto an in-progress list, then return that
list reversed.
Returns an empty list when $this->router is null (standalone
observers used in unit tests carry their own inner middleware via
$this->innerMiddleware only).
Return values
array<int, BaseMiddleware> —Composed innermost-to-outermost; callers
pass to MiddlewareManager::wrap semantics
(first element wraps outermost).
trigger()
Raw dispatch entry: runs global filters then iterates handlers (with per-handler filter check and inner-middleware wrapping). Does NOT apply outer middleware — that responsibility lives in `Router::propagateEvent`, which wraps the full local-observer + sub- router dispatch with `outerMiddleware->wrap()` once per propagation.
public
trigger(object $event[, array<string, mixed> $kwargs = [] ]) : mixed
Mirrors upstream's TelegramEventObserver.trigger (telegram.py:111-130),
which is also outer-middleware-free; upstream's Router.propagate_event
(router.py:152-166) composes
observer.wrap_outer_middleware(_wrapped, ...) once around
_propagate_event's full body — so the parent observer's outer
middleware covers child router handlers too (Fix I2).
The split exists because outer middleware must NOT wrap individual
observer triggers in a multi-router tree (that would skip sub-router
handlers from the outer-middleware scope and re-wrap each ancestor
trigger when sub-router recursion bubbles back up). Anything wanting
to drive this observer through the outer chain should call
Router::propagateEvent instead.
$event is typed object because dispatcher-synthetic events (notably
ErrorEvent, which deliberately does not extend TelegramObject) flow
through the same dispatch primitive. Handler-declared parameter types
are checked by CallableObject's reflection adapter when binding the
event kwarg.
Parameters
- $event : object
- $kwargs : array<string, mixed> = []
-
Dispatcher context (bot, event_context, state, …) merged with filter-result injections before reaching each handler.
Return values
mixed —The first claiming handler's return value, or
UnhandledSentinel::instance() if every handler passed, or
RejectedSentinel::instance() if a global filter rejected.
triggerCore()
The actual dispatch primitive (post-outer-middleware): runs global filters then iterates handlers with per-handler filter check and inner-middleware wrapping.
private
triggerCore(object $event, array<string, mixed> $kwargs) : mixed
Sequence — matches upstream TelegramEventObserver.trigger:
- Iterate global
$filters. Each runs against$event+$kwargs. A falsy result aborts dispatch withRejectedSentinel; an array result is merged into$kwargsfor downstream filter and handler consumption. - Iterate
$handlersin registration order. For each: a. Inject'handler' => $handlerinto kwargs (the dispatcher contract — handlers can declareHandlerObject $handler). b. Run the handler's filter pipeline viaHandlerObject::check. Failure:continueto the next handler. c. Success: invoke the handler via the inner-middleware chain. If the result isUnhandledSentinelthe handler explicitly opts out and wecontinue; otherwise its return value is the trigger result. - If no handler claimed the event, return
UnhandledSentinel.
The event kwarg is injected before filters run so filter and handler
signatures can declare the event as a named parameter. Deviation from
upstream's handler.call(event, **kwargs): we pass event only as
a kwarg, never positional. The PHP kwarg-binding model uses parameter
names, and forwarding event positionally AND as a kwarg collides
with PHP 8.1+ "Named parameter overwrites previous argument" guard.
Sentinel return values are intentionally distinct objects (compared by
identity via ===) so a handler that legitimately returns null is
not confused with "no handler ran".
Parameters
- $event : object
- $kwargs : array<string, mixed>