TqkLibrary.Telegram.BotKit
1.0.34
dotnet add package TqkLibrary.Telegram.BotKit --version 1.0.34
NuGet\Install-Package TqkLibrary.Telegram.BotKit -Version 1.0.34
<PackageReference Include="TqkLibrary.Telegram.BotKit" Version="1.0.34" />
<PackageVersion Include="TqkLibrary.Telegram.BotKit" Version="1.0.34" />
<PackageReference Include="TqkLibrary.Telegram.BotKit" />
paket add TqkLibrary.Telegram.BotKit --version 1.0.34
#r "nuget: TqkLibrary.Telegram.BotKit, 1.0.34"
#:package TqkLibrary.Telegram.BotKit@1.0.34
#addin nuget:?package=TqkLibrary.Telegram.BotKit&version=1.0.34
#tool nuget:?package=TqkLibrary.Telegram.BotKit&version=1.0.34
TqkLibrary.Telegram.BotKit
A lightweight, attribute-driven framework for building Telegram bots on top of
Telegram.Bot. It plugs into
Microsoft.Extensions.DependencyInjection and brings ASP.NET-style routing,
per-chat state, and resource-based localization to the Telegram.Bot client
without forcing you to write a dispatcher by hand.
Why
Writing a non-trivial Telegram bot directly against Telegram.Bot quickly turns
into a giant switch statement over Update types, callback strings, and
ad-hoc Dictionary<chatId, ...> state. This library replaces that boilerplate
with declarative pieces:
[TelegramCommand("start")]for/commandhandlers.[InlineButton("template")]+[CallbackPrefix("...")]for inline-keyboard callback routes (with typed parameter binding).[OnUserInput("key")]for "wait for the next message" flows.[TelegramRegex("...")]for plain-text message matching.- A typed per-chat state class (
BotKitChatStateBase) cached byIMemoryCache, used by both your handlers and the framework's routing/i18n. IStringLocalizer-driven button titles and command descriptions, with culture resolved per-update from the chat state.- A multi-bot host collection so one process can run many bot tokens, in either long-polling or webhook mode.
Status
Pre-release. The public API is still moving. Versioning is driven by GitVersion
({Major}.{Minor}.{Patch}.{CommitsSinceVersionSource}).
Target frameworks
netstandard2.0net6.0net8.0net10.0
Compiles under LangVersion=12.0, Nullable=enable.
Dependencies
Telegram.Bot22.xMicrosoft.Extensions.DependencyInjectionMicrosoft.Extensions.Caching.MemoryMicrosoft.Extensions.Localization
Installation
NuGet (once published):
dotnet add package TqkLibrary.Telegram.BotKit
The repository also ships a NugetPush.ps1 helper and a shared
CsharpNugetPush script for local pack/push workflows.
Quick start
A complete runnable sample lives in
src/TqkLibrary.Telegram.BotKit.SimpleDemo.
Run it with:
dotnet run --project src/TqkLibrary.Telegram.BotKit.SimpleDemo -- <YOUR_BOT_TOKEN>
# or set TELEGRAM_BOT_TOKEN_DEMO and run without args
1. Register the kit
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTelegramBotKit(options =>
{
options.AddCommand<BotCommands>(); // /command handlers
options.AddModulesFromAssemblyOf<MainMenuModule>(); // CallbackModule scan
});
// Single per-chat state class. Inheriting BotKitChatStateBase lets the
// framework auto-bind PendingInputKey + Language without Map lambdas.
builder.Services.AddBotKitChatState<DemoChatState>();
builder.Services.AddLocalization();
builder.Services.AddSingleton<IStringLocalizer>(sp =>
sp.GetRequiredService<IStringLocalizerFactory>().Create(typeof(DemoStrings)));
2. Define commands
public class BotCommands : CommandModule
{
readonly DemoChatState _state;
readonly IStringLocalizer _l;
public BotCommands(DemoChatState state, IStringLocalizer localizer)
{
_state = state;
_l = localizer;
}
[TelegramCommand("start", order: 0, DescriptionResourceName = "DescStart")]
public async Task Start(Message message, CancellationToken ct)
{
await Bot.SendMessage(ChatId, _l["WelcomeText"], cancellationToken: ct);
}
[TelegramCommand("echo", order: 2, DescriptionResourceName = "DescEcho")]
public async Task Echo(Message m, [CommandArg] string? args, CancellationToken ct)
{
string reply = string.IsNullOrWhiteSpace(args) ? _l["EchoNoText"]
: _l["EchoReply", args];
await Bot.SendMessage(ChatId, reply, cancellationToken: ct);
}
}
[CommandArg] captures the text after the command name (e.g. /echo hello world
→ "hello world"), which is also handy for Telegram deep links
(https://t.me/yourbot?start=payload).
3. Define callback modules
[CallbackPrefix("menu")]
public class MainMenuModule : CallbackModule
{
[InlineButton("home", TitleResourceName = "BtnMainMenu")]
public async Task Home(CallbackQuery q, CancellationToken ct) { /* ... */ }
[InlineButton("about", TitleResourceName = "BtnAbout")]
public async Task About(CallbackQuery q, CancellationToken ct) { /* ... */ }
}
Build keyboards by referencing the handler method directly — the framework renders the matching callback data and (optionally) a localized button title:
InlineKeyboardMarkup markup = new(new[]
{
new[] { Registry.ToInlineButton<MainMenuModule>(c => c.Home(default!, default), localizer: _l) },
});
4. Typed route parameters
[InlineButton] templates support placeholders with type constraints. Allowed
types: string, int, long, bool, guid (and union via |, e.g.
{id:guid|int}). The framework validates worst-case UTF-8 length up front so
you never silently exceed Telegram's 64-byte callback_data limit.
[CallbackPrefix("lang")]
public class LanguageModule : CallbackModule
{
[InlineButton("set/{code}")]
public Task Set(string code, CallbackQuery q, CancellationToken ct) { /* ... */ }
}
5. "Wait for next message" flows
Set a routing key on the chat-state, decorate the receiver with
[OnUserInput(key)], and the next plain-text message in that chat is delivered
to that handler:
const string PendingInputKey = "echo:wait_text";
[InlineButton("start", TitleResourceName = "BtnEchoStart")]
public async Task Start(CallbackQuery q, CancellationToken ct)
{
_state.PendingInputKey = PendingInputKey;
// prompt the user...
}
[OnUserInput(PendingInputKey)]
public async Task OnUserText(Message message, CancellationToken ct)
{
_state.PendingInputKey = null; // consume
_state.LastEchoText = message.Text;
// reply...
}
6. Regex matching
[TelegramRegex(@"^hello\b", order: 0, StopOnMatch = true)]
public Task OnHello(Message m, CancellationToken ct) { /* ... */ }
Regex handlers only fire when the text is not a /command and no
PendingInputKey is pending; matches run in Order ascending, with optional
short-circuit via StopOnMatch.
7. Per-chat state
public class DemoChatState : BotKitChatStateBase
{
public int EchoCount { get; set; }
public string? LastEchoText { get; set; }
public int? EchoPromptMessageId { get; set; }
}
The state is cached per (BotId, ChatId) in IMemoryCache with configurable
sliding/absolute expiration. Use the async overload of AddBotKitChatState when
you need to hydrate from a database — the dispatcher pre-loads it before the
handler scope is built so handler constructors stay sync:
builder.Services.AddBotKitChatState<DemoChatState>(async (sp, ct) =>
{
var ctx = sp.GetRequiredService<IUpdateContext>();
var db = sp.GetRequiredService<MyDbContext>();
return await db.ChatStates.FindAsync([ctx.BotId, ctx.ChatId], ct)
?? new DemoChatState();
});
8. Localization
IStringLocalizer is consumed by:
[InlineButton(TitleResourceName = "...")]for button captions.[TelegramCommand(DescriptionResourceName = "...")]for the command menu shown in the Telegram client.- Your own handler code (
_l["WelcomeText"]).
The dispatcher applies CurrentUICulture per update from ICultureProvider
(default reads BotKitChatStateBase.Language), so every resolution runs against
the user's currently selected language. You can publish per-language menus with
SetCommandsAsync<TCommand>(culture, languageCode) and per-chat menus with
scope: new BotCommandScopeChat { ChatId = chatId }.
Handler method parameters
Handler methods ([TelegramCommand], [InlineButton], [OnUserInput],
[TelegramRegex]) accept three categories of parameters. The framework
binds them per-update — you don't write the wiring.
Well-known context types
Matched by type, in any order:
| Type | What it is |
|---|---|
Update |
The full Telegram Update. |
Message |
The message attached to this update (also set for callbacks that carry a message). |
CallbackQuery |
The callback query (only meaningful inside [InlineButton] handlers). |
UpdateType |
The current update kind. |
CancellationToken |
Pipeline cancellation token. |
ModuleContext |
Per-update bundle: ServiceProvider, Bot, BotToken, BotId, ChatId, TelegramUserId, Logger. |
[InlineButton("home")]
public Task Home(CallbackQuery q, ModuleContext ctx, CancellationToken ct) { /* ... */ }
[CommandArg] for /command payload
Captures the text after the command name. Must be string or string?:
[TelegramCommand("echo")]
public Task Echo(Message m, [CommandArg] string? args, CancellationToken ct) { /* ... */ }
Route placeholders
Parameters whose name matches an [InlineButton] placeholder are filled
from the parsed callback data — see Typed route parameters.
[InlineButton("set/{code}")]
public Task Set(string code, CallbackQuery q, CancellationToken ct) { /* ... */ }
DI services and chat-state
DI dependencies (including your chat-state class) must be injected through
the module constructor, not as method parameters. Declaring a service or
chat-state directly on a handler method throws at startup with
Could not bind parameter ....
public class MainMenuModule : CallbackModule
{
readonly DemoChatState _state;
readonly IStringLocalizer _l;
public MainMenuModule(DemoChatState state, IStringLocalizer l) // DI here
{
_state = state;
_l = l;
}
[InlineButton("home")]
public Task Home(CallbackQuery q, CancellationToken ct) // context types only
{
// _state and _l are already available
}
}
Middleware pipeline
Every received update flows through a user-defined pipeline before the
framework dispatches to a handler. Each stage can inspect / mutate flow,
short-circuit, or wrap the rest in a try/catch. Two canonical uses:
| Use case | How |
|---|---|
| Centralised error handling | wrap await next(ctx) in try/catch |
| Gate / authorisation | inspect ctx.Message/CallbackQuery, return to short-circuit |
Stages run in registration order — the first registered is outermost, so place exception handlers first and gates after them.
Functional middleware (inline)
builder.Services.AddTelegramBotKit(options =>
{
options.Use(async (ctx, next) =>
{
// Private-chat-only gate. Short-circuit by NOT calling next(ctx).
if (ctx.Message is { Chat.Type: var t and not ChatType.Private })
{
await ctx.Bot.LeaveChat(ctx.ChatId, ctx.CancellationToken);
return;
}
await next(ctx);
});
});
Class-based middleware (DI)
Implement IBotMiddleware. Register with UseMiddleware<T>(). The instance is
resolved from the per-update scoped provider, so it may take scoped deps.
public sealed class ExceptionLoggingMiddleware : IBotMiddleware
{
readonly ILogger<ExceptionLoggingMiddleware> _logger;
public ExceptionLoggingMiddleware(ILogger<ExceptionLoggingMiddleware> logger) => _logger = logger;
public async Task InvokeAsync(BotMiddlewareContext ctx, BotRequestDelegate next)
{
try { await next(ctx); }
catch (OperationCanceledException) when (ctx.CancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogError(ex, "Bot {BotId}: unhandled (chat={Chat}, user={User})",
ctx.BotId, ctx.ChatId, ctx.TelegramUserId);
// Swallow so the polling loop keeps running.
}
}
}
options.UseMiddleware<ExceptionLoggingMiddleware>();
options.Use(/* gate ... */);
What's in BotMiddlewareContext
Services (scoped IServiceProvider), Bot, BotId, BotToken, Update,
UpdateType, Message?, CallbackQuery?, ChatId, TelegramUserId,
TelegramUsername?, Logger, CancellationToken. Per-action data
(route values, [CommandArg]) is NOT here — that only exists inside the
matched handler, after the terminal stage.
Order of operations per update
- Acquire per-chat lock (when
PerChatSerializeis on — default). - Create per-update DI scope, populate
IUpdateContext, bootstrap chat states, apply culture. - Run the middleware pipeline (this section).
- Terminal stage: invoke the per-update
OnUserInteractionAsynchook, then dispatch to the matched handler.
A middleware that returns without calling next(ctx) skips steps 4 entirely
(no OnUserInteractionAsync, no handler). The outer dispatcher still has a
safety-net try/catch that logs unhandled exceptions so the polling loop
survives even if your middleware throws.
Multi-bot hosting
TelegramBotHostCollection is registered as a singleton and lets one process
manage many bot tokens:
TelegramBotHostCollection bots = host.Services.GetRequiredService<TelegramBotHostCollection>();
TelegramBotHost botHost = await bots.AddAndStartPollingAsync(token, ct);
await botHost.SetCommandsAsync<BotCommands>();
Polling vs. webhook
Mode is chosen per call. Both methods are idempotent — calling them twice with the same token returns the existing host.
AddAndStartPollingAsync(token, ct)— direct long polling against Telegram. No public URL required; ideal for local development.AddAndStartWebhookAsync(token, webhookBaseUrl, ct)— registers a webhook with Telegram. Requires a public HTTPS endpoint.
Webhook setup
The full webhook URL is built as {webhookBaseUrl}/{path}, where {path} is
derived from the bot token by IBotWebhookPathResolver (default: lowercase
hex of SHA-256(token), 64 chars). The same {path} is sent to Telegram as
the secret_token, so the framework can authenticate inbound webhook calls
without you handling that yourself.
TelegramBotHost botHost = await bots.AddAndStartWebhookAsync(
token,
"https://your-public-host.example.com/api/tg",
ct);
Forward incoming webhook requests from your ASP.NET endpoint. The {path}
route value is the same string the resolver produced:
app.MapPost("/api/tg/{path}", async (
string path,
Update update,
TelegramBotHostCollection collection,
CancellationToken ct) =>
{
await collection.HandleWebhookUpdateAsync(path, update, ct);
return Results.Ok();
});
On-demand auto-start
For dynamic environments (bots provisioned outside the process, or many
short-lived bots), use the overload that also takes webhookBaseUrl and
register an IBotTokenResolver. When a webhook arrives for a bot that isn't
running, the resolver looks up the token by path and the host is started on
the fly:
public class DbBotTokenResolver : IBotTokenResolver
{
readonly MyDbContext _db;
public DbBotTokenResolver(MyDbContext db) => _db = db;
public Task<string?> GetBotTokenAsync(string webhookPath, CancellationToken ct)
=> _db.Bots
.Where(b => b.WebhookPath == webhookPath) // index this column
.Select(b => b.Token)
.FirstOrDefaultAsync(ct);
}
builder.Services.AddScoped<IBotTokenResolver, DbBotTokenResolver>();
// In the endpoint:
await collection.HandleWebhookUpdateAsync(
path, "https://your-public-host.example.com/api/tg", update, ct);
When provisioning a new bot, precompute and persist the path with the same resolver so the lookup is O(1):
IBotWebhookPathResolver resolver = sp.GetRequiredService<IBotWebhookPathResolver>();
newBot.WebhookPath = resolver.ResolvePath(newBot.Token);
Custom path derivation
Register your own IBotWebhookPathResolver before AddTelegramBotKit (the
default is wired with TryAddSingleton, so the first registration wins).
The output must be 1-256 chars from A-Z a-z 0-9 _ - (Telegram's
secret_token charset):
public class ShortHashResolver : IBotWebhookPathResolver
{
public string ResolvePath(string botToken)
{
using var sha = System.Security.Cryptography.SHA256.Create();
byte[] hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(botToken));
return Convert.ToHexString(hash, 0, 8).ToLowerInvariant(); // 16 chars
}
}
builder.Services.AddSingleton<IBotWebhookPathResolver, ShortHashResolver>();
builder.Services.AddTelegramBotKit(...);
Architecture overview
┌─────────────────────────────┐
Update ──────▶│ BotUpdateDispatcher │
│ - opens DI scope per update│
│ - sets UpdateContext │
│ - applies CurrentUICulture │
│ - pre-loads chat-state │
└──────────────┬──────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
CommandModule CallbackModule Regex / OnUserInput
([TelegramCommand]) ([CallbackPrefix] + handlers
[InlineButton])
│
▼
ModuleActionRegistry
(route table, BotCommand list,
ToInlineButton helpers)
Key types:
| Type | Role |
|---|---|
TelegramBotHostCollection |
Manages many TelegramBotHost instances keyed by token; routes webhooks by path. |
TelegramBotHost |
One bot lifecycle (polling/webhook), SetCommandsAsync, dispatch entry. |
BotUpdateDispatcher |
Per-update pipeline: scope, context, culture, routing, handler invocation. |
ModuleActionRegistry |
Discovers attributed methods, builds the route table, renders BotCommands and inline buttons. |
RouteTemplate |
Parses, matches, and renders [InlineButton] templates within Telegram's 64-byte budget. |
BotKitChatStateBase |
Optional base for the user's chat-state class — exposes PendingInputKey + Language to the framework. |
IUpdateContext |
Per-update ambient view (BotId, ChatId, UserId, raw Update) injectable into your services. |
ICultureProvider |
Resolves the per-update CultureInfo; default reads chat-state language. |
IBotWebhookPathResolver |
Derives the webhook URL path / Telegram secret_token from a bot token. Default: SHA-256 hex. |
IBotTokenResolver |
Optional lookup of webhookPath → botToken so HandleWebhookUpdateAsync can auto-start unknown bots. |
Project layout
TqkLibrary.Telegram.BotKit/
├── CsharpNugetPush/ shared NuGet pack/push helpers
├── GitVersion.yml GitVersion configuration (loaded by MSBuild)
└── src/
├── Directory.Packages.props
├── ProjectBuildProperties.targets shared TargetFrameworks + GitVersion wiring
├── TqkLibrary.Telegram.BotKit/ the library
├── TqkLibrary.Telegram.BotKit.SimpleDemo/ runnable demo bot
└── TqkLibrary.Telegram.BotKit.Tests/ unit tests
Building
dotnet build src/TqkLibrary.Telegram.BotKit.sln
dotnet test src/TqkLibrary.Telegram.BotKit.sln
dotnet pack src/TqkLibrary.Telegram.BotKit/TqkLibrary.Telegram.BotKit.csproj -c Release
GitVersion drives the version number automatically; the .nuspec next to the
project picks it up via the SetVersionFromGitVersion MSBuild target.
License
MIT. See LICENSE (or the <license> element in the .nuspec).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.7)
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
- Microsoft.Extensions.Localization (>= 10.0.7)
- Telegram.Bot (>= 22.9.6.2)
- TqkLibrary.CompilerServices (>= 1.0.0.3)
-
net10.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.7)
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
- Microsoft.Extensions.Localization (>= 10.0.7)
- Telegram.Bot (>= 22.9.6.2)
-
net6.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.7)
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
- Microsoft.Extensions.Localization (>= 10.0.7)
- Telegram.Bot (>= 22.9.6.2)
-
net8.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.7)
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
- Microsoft.Extensions.Localization (>= 10.0.7)
- Telegram.Bot (>= 22.9.6.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.34 | 98 | 5/23/2026 |