BotRouter class whose HandleUpdateAsync method is a plain switch over update.Type with hard-coded, inlined handler calls. This page explains how the generator works from the inside.
Entry point: PolyBotGenerator
The generator class lives in PolyBot.SourceGenerators and is decorated with [Generator]:
IIncrementalGenerator (Roslyn’s incremental API), which means the generator registers value providers once and Roslyn only re-runs the expensive parts when relevant inputs change. The full pipeline runs once per compilation; individual pipeline stages are cached by Roslyn between edits.
Library vs executable detection
The very first thing the generator does is determine whether the consuming project is a library or a runnable assembly:build_property.OutputType MSBuild property is read from the analyzer config options. "Library" → library mode; anything else ("Exe", "WinExe", absent) → executable mode.
This flag gates which emitters run: filter attributes, With* extensions, and keyboards always run; BotRouter, PolyBotExtensions, BotFather sync, and hosting integrations only run for executables.
Discovery phases
FilterDiscovery
Scans the compilation’s own assembly (not referenced assemblies) for concrete, non-generic classes that implement IUpdateFilter. It walks namespaces recursively and builds a list of FilterClassModel records containing:
TypeFqn— fully qualified type name for use in emitted codeShortName— the class name without any namespace prefix, used to derive the[XxxFilter]attribute nameDtoBaseName— the name of the generated abstract DTO base the filter subclasses (e.g.MessageFilter), ornullfor rawIUpdateFilterimplementations
CUR012 (warning) and are skipped. The filter list is shared across all subsequent phases so cross-phase consistency is guaranteed within a single compilation.
Referenced assemblies are not scanned up front. Instead, when a handler attribute or With* extension in a referenced library is encountered during handler discovery, FindFilterInAssembly walks the referenced assembly’s namespace tree on demand to find the concrete filter type paired with the wrapper.
HandlerDiscovery
Runs as a syntax-first incremental pipeline: the predicate selects any method with at least one attribute list, and the transform step performs the full semantic analysis per method. For each candidate method it:
- Locates the handler attribute — looks for an attribute that inherits
HandlerAttribute(or, for error-type symbols on the first pass, matches the stem againstUpdateTypeenum member names). Stacking a second handler attribute (e.g.[UpdateHandler]+[MessageHandler]) on one method is rejected withCUR041. - Resolves the update type — reads
UpdateHandlerDescriptorAttributeon the handler attribute class first; falls back to the naming convention (MessageHandlerAttribute→UpdateType.Message).[UpdateHandler]is special-cased: it resolves to the rawTelegram.Bot.Types.Update, reads itsTypesarray into a (possibly empty = universal) member list, and is validated separately (CUR040when noUpdateparameter exists,CUR043on[Command]/[Pattern]/state guards). - Classifies the return type —
Task<Result>,ValueTask<Result>→HandlerReturnKind.Result;Task/ValueTask→HandlerReturnKind.Plain; anything else →CUR001. - Processes
[Command]and BotFather metadata — readsAliases,Prefix,Description,Scope,LanguageCode,IsHidden. Validates alias format (CUR020), checks description length (CUR022). - Processes
[Arg],[Rest],[Parse]— validates types and ordering, buildsArgModelandTailConsumerModellists. - Resolves filter attributes — matches attribute names against the
FilterClassModellist (own-assembly filters) or walks referenced assemblies on demand (library filters). - Processes
[State<TEnum>]/[NoState<TEnum>]— folds same-pair conditions into OR groups; AND-links distinct pairs; reportsCUR011on mutual exclusion. - Classifies handler parameters —
ITelegramBotClient,CancellationToken,Update, payload,[Arg]/[Rest]/[Parse],IStateMachine,IStateStorage,IUpdateAwaiter,[Key]keyed services, or plain DI service. - Extracts await sites — walks
AwaitExpressionnodes inside the method body, followsWaitFor*Asynccall chains, and records the DTO type,TextMatchespatterns,WithFilter<T>types,Wherelambdas, and terminal key mode (ByUserId,ByChatId,ByUserInChat). ReportsCUR013–CUR016as appropriate. - Processes
[Pattern]— parses the template intoPatternSegmentModellist, validates placeholder ↔[Arg]bindings, checks 64-byte limit, resolvesTryParsekind. - Processes
[Throttled]— readsLimit,PeriodMilliseconds,Scope,Action; reportsCUR035on invalid values.
KeyboardDiscovery
Selects methods and properties (C# 13) with at least one attribute list. For each candidate it looks for [CallbackButton] attributes, groups them by attribute list index (each list index becomes a keyboard row), and extracts {parameter} placeholder names from callback data strings.
ExceptionHandlerDiscovery
Selects methods with attribute lists, looks for [ExceptionHandler], validates return type (CUR034) and parameters (CUR032), and builds an ExceptionHandlerModel. Duplicate [ExceptionHandler] declarations are handled in the output phase (CUR033).
Emitters
After discovery, the collected models are passed to a set of emitters. Each emitter returns astring? — null means nothing to emit.
BotRouterEmitter
Generates PolyBot.BotRouter (a partial class). The class implements IUpdateHandler and contains:
- A
private readonly IServiceProvider _servicesfield - An optional
private readonly PolyBotOptions? __curatorOptionsfield (when any handler uses[Command]) - Per-await-site static
string[]andType[]constant fields for the declarative signature arrays - Per-throttle-gate static
ThrottleGatefields - A constructor taking
IServiceProvider HandleUpdateAsync— aswitchonupdate.Type, onecaseper discovered update type (plus any update types referenced only via await sites), with inlined handler branchesHandleErrorAsync— calls the[ExceptionHandler]method (if any); otherwise returnsTask.CompletedTask
switch sections are built in alphabetical order by update type member name for determinism. Within each case:
- Await-site branches come first (static pre-filter conditions →
TryDeliverAsync→returnif claimed) - Handler branches follow in descending
Priorityorder - A
breakterminates the case
FilterAttributesEmitter
Generates PolyBotFilterAttributes.g.cs with one attribute class per discovered filter, e.g.:
PolyBot.Attributes namespace and use AllowMultiple = true.
When the filter has public constructors with attribute-legal parameters (primitives, string, enums, Type, arrays), the wrapper mirrors them: one attribute constructor per mirrored signature, plus a settable property per parameter so optional parameters can also be set by name. PolyBot.csproj itself runs the generator as an analyzer, so the built-in PolyBot.Filters ship with their wrappers in the library assembly.
AwaiterExtensionsEmitter
Generates PolyBotAwaiterExtensions.g.cs (namespace PolyBot.Attributes) with one With{FilterName}(this UpdateAwaiterBuilder<TDto>) extension per discovered concrete filter. The With* method is typed to the filter’s DTO base (MessageFilter → UpdateAwaiterBuilder<Message>) when possible, or generic (UpdateAwaiterBuilder<TUpdate>) otherwise. Filters with attribute-legal constructor parameters additionally get one With* overload per mirrored constructor, constructing the filter with the supplied arguments (builder.WithFilter(new TFilter(args))).
The
WaitFor{UpdateType}Async(this IUpdateAwaiter, TimeSpan?) entry-point extensions are not emitted by PolyBotGenerator. They are emitted by the companion PolyBot.InternalsGenerators project directly into the PolyBot library assembly (also into namespace PolyBot.Attributes, class PolyBotAwaiterExtensions). Both generators emit into the same class name, which is a partial class split across two generated files.KeyboardMarkupEmitter
Generates PolyBotKeyboards.g.cs with the implementation of each partial method or property annotated with [CallbackButton]. Each attribute list maps to a new InlineKeyboardButton[] { ... } row; {parameter} placeholders in callback data are replaced with string interpolation.
BotFatherSyncEmitter
Generates PolyBotBotFatherSync.g.cs containing the PolyBotBotFatherSync : IBotFatherSync class. It groups commands by (Scope, LanguageCode), excludes hidden commands from DiscoveredCommands, and calls SetMyCommands once per group in SyncCommandsAsync.
HostingEmitter
Generates conditional hosting integrations by inspecting referenced assemblies:
PolyBotHosting.g.cs— emitted whenMicrosoft.Extensions.Hosting.Abstractionsis referenced. ContainsAddPolyBotHostedPolling(...)extension andPolyBotPollingHostedService : IHostedService.PolyBotWebhook.g.cs— emitted when anyMicrosoft.AspNetCore.*assembly is referenced. ContainsAddPolyBotWebhook(),MapPolyBotWebhook(string), andPolyBotWebhookHostedService : IHostedService.
PolyBotExtensionsEmitter
Generates PolyBotExtensions.g.cs containing the AddPolyBotRouter() extension method on IServiceCollection, which registers BotRouter as IUpdateHandler (singleton) and PolyBotBotFatherSync as IBotFatherSync (singleton).
What gets generated: summary
The PolyBot.InternalsGenerators companion
A second generator project, PolyBot.InternalsGenerators, generates the internal boilerplate that the main generator reads. It produces:
- The abstract handler attribute bases (
MessageHandlerAttribute, etc.), one perUpdateType, each stamped with[UpdateHandlerDescriptor(UpdateType.Message, "Message")] - The abstract generated filter DTO bases (
MessageFilter,CallbackQueryFilter, etc.) with theirCanPass(Update)implementations that extract the payload - Per-update-type narrowing filter bases for DTOs shared across multiple update types
- The
WaitFor{UpdateType}Async(this IUpdateAwaiter, TimeSpan?)extension methods in namespacePolyBot.Attributes(classPolyBotAwaiterExtensions), compiled directly into thePolyBotlibrary assembly — these are always available regardless of whether anyIUpdateFilteris discovered in the consuming project
PolyBot.dll (the library the user references) never directly depends on Telegram.Bot’s UpdateType enum at the source level — the generated bases do — and the main generator can read the handler attribute metadata from compiled symbols rather than hardcoding UpdateType member names.
Cross-assembly filter resolution
When a handler in an executable references a filter attribute ([NonEmptyTextFilter]) or With* extension (WithNonEmptyTextFilter()) that was emitted for a filter class living in a referenced library assembly, the generator resolves the pairing on demand:
- The filter attribute class (or the
With*extension type) comes from the referenced assembly. - The generator calls
FindFilterInAssembly(assembly, stemName, filterInterface), which walks the referenced assembly’s namespace tree recursively looking for a concrete, non-generic class namedstemNamethat implementsIUpdateFilter. - The fully qualified type name of the found class is used in the emitted
BotRoutercode.