Lds.Bot.Library 1.1.1
Bot.Library
A small engine for multi-turn Telegram conversations in .NET.
Telegram gives you a stream of disconnected messages. Anything worth doing needs several — "register time" → "which case?" → "which customer?" — and the user may answer a minute later, from a different message, after the process has restarted. This library turns that stream into dialogs made of steps, with the conversation's position persisted after every turn.
It is deliberately transport-agnostic and host-agnostic: it holds no receive loop, no token, no
web server and no opinion about your application's users. You feed it Update objects; it decides
what to say.
- Package:
Lds.Bot.Library· Target: .NET 10 · Depends on: Telegram.Bot 22.10,Microsoft.Extensions.{DependencyInjection,Logging}.Abstractions— and no database at all. Storage is a separate package:Lds.Bot.Library.Mongo(MongoDB.Driver 3.10)
dotnet add package Lds.Bot.Library.Mongo # brings the engine with it
The package ids carry the organisation; the assemblies and namespaces do not.
using Bot.Library;is what it always was.
- What it cannot do: Known limitations — read this before designing, not after. Inline keyboards in particular are not really supported
- Everything else about using it is in this file. It ships inside the package, so if you have the
package you have the manual:
~/.nuget/packages/lds.bot.library/<version>/readme.md. Alongside it,AGENTS.mdis the short version, andBot.Library.xmlputs ~329 documented members into IntelliSense
A note on references marked (internal). A few passages point at
Tempuro.Bot.Server, the Datalogik application this engine was built for, or at this repository's ownroadmap.md. Those are not reachable from outside Datalogik, and nothing in this document depends on them — they are corroboration for colleagues, never the only place something is explained. If you find one that is load-bearing, that is a bug in this file.
Contents
- What it does and does not do — including known limitations
- The model
- Quick start
- Dialogs and steps
- Carrying state across turns
- Commands
- Persistence — including administration and settings
- Transports
- Concurrency
- Logging and errors
- Testing
- Rules that are easy to get wrong
- API reference
1. What it does and does not do
It does:
- Route an incoming message to a dialog, and remember which dialog a chat is in the middle of
- Run a dialog as a sequence of steps, one user turn at a time
- Persist the current step and the dialog's own state between turns, so nothing is lost across a restart or a deployment
- Serialise concurrent updates for the same chat
- Handle slash commands centrally, before dialogs see the message
- Keep its own record of each Telegram user it has met, and give you the services to administer them — block, trash, delete, inspect — without opening the database
It does not:
- Receive updates. You run a webhook endpoint or a polling loop and call
DispatchAsync - Own the bot token, or read configuration
- Know anything about your application's users, permissions or sign-in. Bot users are Telegram identities; mapping one to your user is the host's job (see Identity)
- Handle inline queries, payments, outbound media, or (usefully) callback queries — see Known limitations below. It can receive a file the user sent — see Receiving a file
Known limitations
Four of these shape a design, so they are worth knowing before you start rather than after. Each is a deliberate present state, not an oversight — but none of them is defended, and all four are additive to fix.
Reply keyboards only. Inline buttons are not really supported.
Dialog.Process(CallbackQuery, UserInfo) answers with "I don't like callbacks in the dialog!"
unless a dialog overrides it, BotController.GenericCallback is an empty virtual, and IBotMessenger
has no AnswerCallbackQuery — so a tapped inline button spins in the client forever. Build menus with
ReplyKeyboardMarkup.
If you add callback routing: encode the dialog's Key, never its Name. A callback payload sits
in the user's chat history until they tap it, possibly for weeks, so renaming a dialog would otherwise
break every button already sent. Telegram caps the payload at 64 bytes.
The engine sends text, and nothing else.
IBotMessenger is SendMessage and EditMessageText. No SendPhoto, no SendDocument, no
DeleteMessage, no SendChatAction, and no SetMyCommands — which means your command menu has to be
maintained by hand in BotFather and will drift from your code. Receiving a file is supported; see
Receiving a file.
One process per bot token.
ChatLockProvider serialises turns per chat using 64 in-process semaphores. Two instances behind a
load balancer would both accept webhook deliveries for the same chat and race on the conversation
document — silently, not loudly. Telegram wants one owner per token anyway, so this holds until someone
scales the host out for unrelated reasons.
Only Message and CallbackQuery updates are handled.
Edited messages, channel posts, inline queries, chat member changes, polls and reactions are logged at
Trace and dropped. Fine for a command bot; a blocker for anything that needs to react to them.
Two more things that are not limitations but are the mistakes people actually make — the full list is in Rules that are easy to get wrong:
- A dialog's registration key and its step order are persisted data. Changing either strands conversations that are mid-dialog.
ResumeDialog<T>steps must take() => StateData, never the state object itself.
2. The model
Telegram ──Update──▶ your transport ──▶ IBotUpdateDispatcher
│ opens one DI scope per update
▼
BotController.ProcessAsync
│
┌─────────────────────────────────────┴──────────────────────────────┐
│ 1. acquire the chat's lock │
│ 2. load UserInfo + ConversationState (resume dialog if mid-flow) │
│ 3. slash command? ──yes──▶ run it, done │
│ 4. no dialog running? ──▶ FindDialogKeyAsync ──▶ build dialog │
│ 5. dialog.Process(message) ──▶ runs steps │
│ 6. still Waiting? persist step + state. Otherwise drop the dialog │
│ 7. save whatever is dirty │
└────────────────────────────────────────────────────────────────────┘
Four ideas carry the whole design:
| Idea | Meaning |
|---|---|
| Turn | One update in, some messages out. All state is loaded and saved within it — nothing is cached between turns |
| Dialog | A named, multi-turn flow. Registered under a stable key, which is what gets persisted |
| Step | One question and one answer inside a dialog. Returns an IStepResult saying where to go next |
| Finalizer | Optional. Runs once when a dialog completes — this is where the dialog's actual work belongs |
State lives in the database, not in memory. A dialog object exists only for the duration of one turn; the next turn rebuilds it from the container and restores its position. This is what makes webhook delivery and rolling restarts safe.
A dialog does not wait for ever. If BotOptions.DialogTimeout is set and the conversation's last
turn is older than it, the dialog is dropped, the user is told, and their message is routed as
ordinary input instead of being fed to a question they have long since forgotten. Null — the default —
resumes a dialog of any age.
3. Quick start
A complete bot that asks for a pizza size and then an address.
3.1 A dialog
using Bot.Library.Abstractions;
using Bot.Library.Dialogs;
using Bot.Library.Dialogs.Steps;
using Bot.Library.Extensions;
using Telegram.Bot.Types.ReplyMarkups;
/// <summary>State carried across turns. Must be JSON-serializable.</summary>
public class OrderState {
public string Size { get; set; } = "";
public string Address { get; set; } = "";
}
public class OrderDialog : ResumeDialog<OrderState> {
// Dependencies are injected: the dialog is resolved from the turn's DI scope.
public OrderDialog(IBotMessenger bot, IOrderService orders) : base(bot, "Order") {
StateData = new OrderState();
// Pass an ACCESSOR, never StateData itself — see §5.
AddStep(new AskSizeStep(nameof(AskSizeStep), () => StateData));
AddStep(new AskAddressStep(nameof(AskAddressStep), () => StateData));
SetFinalizer(new PlaceOrderFinalizer(nameof(PlaceOrderFinalizer), () => StateData, orders));
}
private class AskSizeStep(string name, Func<OrderState> state) : Step<OrderState>(name, state) {
// Process handles the message that arrives while this step is current.
public override async Task<IStepResult> Process(TurnData turnData) {
var text = turnData.Message.GetText();
if (text is not ("small" or "large")) {
await Bot.SendMessage(turnData.Message.Chat.Id, "Small or large?",
replyMarkup: new ReplyKeyboardMarkup([[new KeyboardButton("small"), new KeyboardButton("large")]]));
return ChangeTurn; // wait for the user
}
StateData.Size = text;
return MoveToNextStep; // advance; the next step's Prompt runs immediately
}
}
private class AskAddressStep(string name, Func<OrderState> state) : Step<OrderState>(name, state) {
// Prompt runs when the dialog ARRIVES at this step.
public override async Task<IStepResult> Prompt(TurnData turnData) {
await Bot.SendMessage(turnData.Message.Chat.Id, "Where should it go?",
replyMarkup: new ReplyKeyboardRemove());
return ChangeTurn;
}
// Process runs on the NEXT turn, with the user's answer.
public override async Task<IStepResult> Process(TurnData turnData) {
var text = turnData.Message.GetText();
if (text.IsBlank()) return await Prompt(turnData);
StateData.Address = text;
return FinalizeDialog; // runs the finalizer, then ends
}
}
private class PlaceOrderFinalizer(string name, Func<OrderState> state, IOrderService orders)
: Finalizer<OrderState>(name, state) {
public override async Task Process(TurnData turnData) {
await orders.PlaceAsync(StateData.Size, StateData.Address);
await Bot.SendMessage(turnData.Message.Chat.Id, $"On its way — {StateData.Size}, to {StateData.Address}.");
}
}
}
3.2 A bot
Derive from BotController and supply routing only.
using Bot.Library;
using Bot.Library.Commands;
using Bot.Library.Dialogs;
using Bot.Library.Models;
using Telegram.Bot.Types;
public class PizzaBot : BotController {
public PizzaBot(BotDependencies deps) : base(deps) {
var cancel = new CancelCommand();
Commands.Add("cancel", cancel); // keyed WITHOUT the leading '/'
Commands.Add("reset", cancel);
Commands.Add("debug", new DebugCommand());
}
/// <summary>Which dialog should handle this message? Return its registered key, or null.</summary>
public override Task<string?> FindDialogKeyAsync(Message message, UserInfo userInfo) {
// TrySimpleText matches the FIRST WORD, so "order two pizzas" still matches.
if (Dialog.TrySimpleText(message, ["order", "/order"])) return Task.FromResult<string?>("Order");
return Task.FromResult<string?>(null);
}
/// <summary>What to say when nothing claimed the message.</summary>
public override async Task FallBack(Message message) {
LogMessage(message);
await Bot.SendMessage(message.Chat.Id, "Say 'order' to order a pizza.");
}
}
3.3 Wiring
using Bot.Library;
using Bot.Library.Abstractions;
using Bot.Library.Infrastructure;
using Bot.Library.Mongo; // only if you want the Mongo stores
using MongoDB.Driver;
using Telegram.Bot;
var database = new MongoClient(connectionString).GetDatabase("mydatabase");
var client = new TelegramBotClient(botTokenFromConfig); // your token, from configuration or a vault
builder.Services.AddBotEngine(
client,
options => {
options.BotId = "PizzaBot"; // STABLE. Changing it orphans every conversation
options.BotName = "PizzaBot";
},
dialogs => dialogs
.Add<OrderDialog>("Order")); // the key persisted on conversations
// The engine names no store. This is the call that decides where it persists — swap it for your own
// four registrations and the engine neither knows nor cares.
builder.Services.AddBotMongoStores(database);
// AddBotEngine does NOT register these three — they are yours to choose:
builder.Services.AddScoped<PizzaBot>();
builder.Services.AddSingleton<IBotUpdateDispatcher, BotUpdateDispatcher<PizzaBot>>();
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
// Declare the bot collections' indexes. Explicit rather than automatic: the library owns no
// background work, and registration is synchronous. Idempotent — call it on every start.
await database.EnsureBotIndexesAsync(app.Services.GetRequiredService<BotOptions>());
AddBotEngine (core) registers:
| Lifetime | Registrations |
|---|---|
| Singleton | BotOptions, the ITelegramBotClient you passed, DialogRegistry, ChatLockProvider |
| Scoped | IBotMessenger, IDialogFactory, BotDependencies, and every registered dialog type |
AddBotMongoStores (Bot.Library.Mongo) registers the IMongoDatabase you passed as a singleton,
and IConversationStore, IBotUserStore and IBotSettingsStore as scoped. IBotAdministration
is registered by AddBotEngine instead — it is composed from those three rather than being one of them.
Call both, or call the first and register the three ports yourself.
Build your provider with ValidateScopes = true in development. Resolving a scoped service from the
root would hand back the same dialog instance every turn, and the resume path would silently never
run.
3.4 Receiving updates
See §8. The shortest thing that works, for development:
await client.ReceiveAsync(
updateHandler: async (_, update, ct) => await dispatcher.DispatchAsync(update, ct),
errorHandler: (_, ex, _, ct) => { logger.LogError(ex, "polling error"); return Task.CompletedTask; },
new ReceiverOptions { AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery] },
cancellationToken);
4. Dialogs and steps
Prompt vs Process
This is the one thing to understand properly.
Processhandles the message that arrived while this step was current. Mandatory.Promptruns when the dialog arrives at the step, to ask the question. Optional — the base throws if you never override it, which is fine for steps that are only ever arrived at with an answer already in hand.
The first step of a dialog is special: the message that started the dialog goes straight to its
Process, so the trigger word is available to it. (Dialog.Process calls
NextStep(skipToProcessOfNextStep: true).) Every later step reached by MoveToNextStep gets its
Prompt called first.
A common shape is therefore: Process validates; if the input is bad it re-prompts, if good it
advances.
Step results
Returned from Prompt and Process; available as protected members of Step.
| Result | Effect |
|---|---|
ChangeTurn |
Stop here and wait for the user. The dialog is persisted (state Waiting) |
MoveToNextStep |
Advance to the next step by registration order and call its Prompt |
MoveToStep(int step) |
Jump to a step by index |
MoveToStep(string name) |
Jump to a step by name. Throws if no such step exists |
FinalizeDialog |
Run the finalizer, then end the dialog (state Complete) |
FinalizeDialogSkipFinalizer |
End without running the finalizer |
CancelDialog |
Say so, clear the keyboard, end (state Cancelled). The wording is IBotTextProvider.ConversationEnded |
Both MoveToStep overloads take skipToProcessOfNextStep — pass true to run the target's
Process instead of its Prompt, i.e. to hand it the current message.
Ready-made steps
| Step | Use |
|---|---|
MessageOnlyStep(name, Func<TurnData, Task<string>>, parseMode) |
Single-turn dialog: compute a reply, send it, finalize |
MoveOnStep(name?) |
Consume the triggering message and move on. Useful as a first step |
ProcessOnlyStep(name, Func<TurnData, Task<IStepResult>>) |
Inline logic without declaring a class |
Dialog base classes
Dialog— stateless across turns. Fine for anything that finishes in one turn, or whose steps need nothing remembered.ResumeDialog<T>— hasStateDataof typeT, serialized to JSON (System.Text.Json) and stored on the conversation between turns.Tmust be a class with a public parameterless constructor and public settable properties.
Both take (IBotMessenger bot, string name) in their constructor. Name is for logging and
messages; the key you register under is separate and is what gets persisted.
Receiving a file
Nothing filters non-text messages out before routing: FindDialogKeyAsync gets the raw Message, and
so does every step through TurnData.Message. The text helpers (GetText, TrySimpleText,
TryRegexText) return empty or false for a photo — they answer questions about text — but the message
itself reaches your dialog either way.
public override Task<string?> FindDialogKeyAsync(Message message, UserInfo userInfo) {
if (message.TryGetAttachment(out _)) return Task.FromResult<string?>(DialogKeys.Upload);
...
}
TryGetAttachment yields a BotAttachment for a document or a photo, and resolves the thing that
is easy to get wrong: a photo arrives as several sizes ordered smallest first, and the one the user
meant is the last. Video, audio, voice and stickers are deliberately not matched.
Then IBotFiles fetches the bytes:
public class UploadDialog(IBotMessenger bot, IBotFiles files) : ResumeDialog<UploadState> { ... }
// inside a step
if (attachment.FileSize > IBotFiles.MaxDownloadSize)
throw new DialogExecutionException("That file is too big for me — 20 MB is my limit.");
using var stream = await files.DownloadAsync(attachment.FileId);
Three things worth knowing:
- A photo has no filename and no MIME type. The library does not invent one, because naming a file you are about to store permanently is your decision. The fact you need: a Telegram photo is always JPEG, whatever the sender's original format was.
- The 20 MB limit is exposed, not enforced.
IBotFiles.MaxDownloadSizeis Telegram's ceiling forgetFile; compareBotAttachment.FileSizeagainst it and refuse in your wording — the library has none of yours to use. - Persist the
FileId, never the bytes. A file id stays valid for as long as the message exists, so it survives inStateDataacross turns; aMemoryStreamdoes not, andStateDatais JSON.
For collecting several files under one question — Telegram delivers an album as one update per photo,
so files two through five arrive while you are waiting for the answer to the first — return
ChangeTurn from Process. That leaves the current step exactly where it is, so the next file is
queued rather than mistaken for the answer.
Outbound media is still missing: there is no SendPhoto or SendDocument. See
Known limitations.
5. Carrying state across turns
ResumeDialog<T> handles the plumbing, but there is one rule you must follow:
Step<T>andFinalizer<T>take aFunc<T>accessor, not aT.
AddStep(new AskSizeStep(nameof(AskSizeStep), () => StateData)); // correct
AddStep(new AskSizeStep(nameof(AskSizeStep), StateData)); // wrong — will not compile
Resuming a dialog deserializes a new state object onto StateData. A step that captured the
original instance at construction time would keep reading and writing the pre-resume object, and
everything the user told you on earlier turns would vanish — silently, with no error. Reading through
the accessor always sees the dialog's current state.
What gets persisted on each waiting turn: the dialog key, the current step index, and the
JSON of StateData.
6. Commands
Slash commands are handled by BotController before the active dialog sees the message. That is
what makes them a reliable escape hatch: they work from any step of any dialog, and no step has to
remember to check for them.
Commands.Add("cancel", new CancelCommand()); // key WITHOUT '/', matched case-insensitively
Write your own by deriving from Command:
public class WhoAmICommand : Command {
public override Task<string> Execute(UserInfo userInfo, ConversationState conversation, string text)
=> Task.FromResult($"You are {userInfo.FullName()}");
}
The returned string is sent with the command's ParseMode (default Html) and a
ReplyKeyboardRemove.
Built in:
| Command | Does |
|---|---|
CancelCommand |
Abandons the dialog in progress, naming it. Register this — a user with no way out of a half-finished dialog is stuck |
DebugCommand |
Replies with the user id and conversation id |
Unmatched slash commands fall through to FindDialogKeyAsync. That is deliberate — it lets
/start be routed like any other trigger — but it means a mistyped command becomes ordinary input
to whatever dialog is running. The parser tolerates /cancel@YourBot (what Telegram sends in groups)
and arguments after the command.
If you list commands in BotFather's /setcommands, remember the menu sends /help, not help —
your routing must accept the slash form.
7. Persistence
Three collections, in your database, on your connection:
| Collection | Holds |
|---|---|
BotUserInfo |
One document per Telegram user the bot has met |
BotConversationState |
One per (bot, user): chat id, current dialog key, step index, state JSON |
BotSettings |
Per-bot settings an administrator may change while the bot runs: PurgeUsersIdleFor, DialogTimeout |
The models are plain objects. They carry no storage attributes, no base class and no reference to any
driver — the mapping lives entirely in Bot.Library.Mongo, which is a separate package you do not
have to take. PropertyChanged.Fody change tracking is core behaviour and stays: only dirty documents
are written at the end of a turn.
Indexes and retention
EnsureBotIndexesAsync(options) declares what the stores need. Call it once at startup, after
building the provider; it is idempotent and safe on every start.
| Index | On | Why |
|---|---|---|
bot_conversation_lookup |
ConversationState (BotId, UserId) |
The lookup every turn performs. Without it, each turn is a collection scan |
bot_conversation_ttl |
ConversationState.LastModified |
Expiry. Only created when BotOptions.ConversationRetention is set |
UserInfo gets no declared index: it is only ever fetched by _id, which Mongo indexes for free.
BotOptions.ConversationRetention (TimeSpan?, default null = keep forever) is the expiry
window, and is persisted behaviour, not a runtime preference:
- Shortening it deletes every conversation already older than the new window, on the next
EnsureBotIndexesAsync. mongod acts within about a minute. - Setting it back to null drops the index and stops the expiry. It brings nothing back.
- Changing it does not drop and recreate — a
collModadjusts the window in place, so a large collection is never left without the index.
Losing a conversation costs a user mid-dialog one restart, and an idle user nothing: the document
holds a chat id, the current dialog's key and step, and the state JSON. UserInfo is the record that
matters and is deliberately never expired.
The window is measured from the last write, not the last message — only turns that change something rewrite the conversation. That is the intended reading: a conversation that has gone stale without changing is exactly the one holding nothing worth keeping.
Before enabling retention on a database that predates timestamping, run
BackfillBotTimestampsAsync. Documents written before the stores stampedLastModifiedcarry0001-01-01, which every TTL window is already long past — the index would empty the collection.
On any database written before 2026-08-05, run
MigrateBotTrashedFieldAsynconce. The user record's trash stamp used to be stored asDeletedand is nowTrashed. The class maps tolerate unknown elements — they have to, or a document written by any other version of this package would throw — so an unmigratedDeletedis silently ignored: every trashed account reads back as active, and the purge's clock restarts. There is no error to notice. The migration is a single$rename, is idempotent, and is worth running even if you believe nothing was ever trashed.
Settings you can change without a deployment
Two of the engine's values are editable while it runs. The rest are not, and the line is drawn by whether a setting reshapes storage rather than by how likely you are to want to change it:
| Where | Changing it | |
|---|---|---|
PurgeUsersIdleFor |
BotSettings |
Editable at runtime. Nothing acts on it until a purge is run |
DialogTimeout |
BotSettings |
Editable at runtime. Changes what happens next time someone returns late; abandons nothing |
ConversationRetention |
BotOptions |
Deployment only. It is the window on a TTL index, and shortening it deletes every conversation already older than the new value, within about a minute |
BotId, BotName, VerboseErrors |
BotOptions |
Deployment only. Identity and a development aid |
An admin screen should say why retention is missing rather than quietly omitting it. A settings form that silently destroys data would be a worse feature than a redeploy, which is the whole reason it is not here.
The store is the durable record; the registered BotOptions is the working copy. Call
await app.Services.LoadBotSettingsAsync() once at startup — after AddBotEngine and the store
registration, before the first update is dispatched — and change settings through
IBotAdministration.SaveSettingsAsync, which writes both.
That split exists because DialogTimeout is read on every turn. Reading the settings document
instead would add a third database round trip to a turn that does two, to support a value that changes
perhaps twice a year.
The first load seeds; every load after that overrides. With no stored record, your configured
BotOptionsvalues are written as the settings — so adopting this on a running bot keeps its behaviour rather than resetting it. Afterwards the stored record wins, and editing the configured value and redeploying will do nothing. That is the point of the feature and also the surprise worth knowing in advance.Across several instances, the others keep their old copy until they restart. One Telegram token means one owner, so today that cannot arise — but it is the same single-instance assumption as the chat lock — see Known limitations — and it is documented rather than solved.
Replacing the stores
IConversationStore, IBotUserStore and IBotSettingsStore are ports — register your own
implementation instead of the Mongo one and the engine neither knows nor cares.
Nine contracts are not optional. Each is something the engine relies on and cannot check, so a store can break the bot while compiling cleanly:
| Contract | What breaks without it |
|---|---|
GetAsync and InsertAsync return clean documents — call MakeClean() before returning |
Deserialization runs the property setters, which is what raises the dirty flag. Every turn then rewrites both documents whether or not anything changed |
InsertAsync restores a trashed user rather than inserting over one |
The id is the Telegram account's. Insert over a trashed record and it is a duplicate key on every message from that account, forever |
The store stamps Created/LastModified, never the model |
A model-level setter raises the dirty flag every turn, defeating the change tracking the save path depends on. ITimestamped carries the reasoning |
LastModified means last written, not last seen |
Both the conversation TTL and the stale-dialog check read it. A store that touched it on read would expire nothing; one that never touched it would expire everything |
| The conversation store assigns an id on insert; the user store never does | A conversation has no natural key and arrives holding only BotId, UserId and ChatId. A user's id is the Telegram account id — generate one there and you have invented a second person |
| Deletes go through the store, not your driver | DeleteAsync refuses a blocked user. Delete around it — a bulk query in a purge, say — and a block is quietly lifted by the next message. TrashAsync needs no such guard: it keeps the document, so it keeps the block |
| A null written over a value reaches storage, rather than being omitted from the write | Lifting a block clears BlockedAt, BlockedBy and BlockedReason by writing nulls. A store that composed its update from the fields that happen to be set would leave the old "blocked by" on an account that is no longer blocked, and every screen reading the record would report a decision that had been reversed |
Only InsertAsync's resurrection stamps AutoRestoredAt — RestoreAsync never does |
The two are different events: somebody deciding, versus the trash emptying itself. A store that stamps on both paths or on neither destroys the only thing the field is for, and an operator watches a record they trashed vanish from the trash with no explanation |
Listings order by id and include trashed users, unlike GetAsync |
A collection has no natural order. Without an explicit sort, paging can repeat one record across two pages and omit another — which reaches a user as "somebody in the list does not exist", nobody's first guess at a sorting bug. And a store author who read only GetAsync will carry its hide-the-trashed rule across, which is wrong here |
The timestamping, LastModified, id-assignment and delete-through-the-store contracts were learned
the hard way; the retention work turned up two live defects and two test doubles that had drifted from
contracts their tests were supposedly proving. (The full account is in this repository's
roadmap.md — internal.)
The contracts are not only documented, they are executable: Bot.Library.Tests/Contracts/ holds one
abstract fixture per contract, phrased against the port interfaces alone. Subclass them over your store
and you inherit the suite that the in-memory fakes and the Mongo adapter both have to pass. That is the
cheapest way to find out whether a store you wrote honours the nine above — and it exists because twice
a fake quietly diverged from a contract and made its tests pass over a broken implementation.
Blocking a user
Set UserInfo.Blocked and the engine ignores that account: the turn stops as soon as the user is
loaded, before any command or dialog runs, and nothing is sent back. Telling someone they are
blocked only confirms the bot is listening; silence is indistinguishable from a bot that is switched
off. The ignored update is logged at Debug.
Nothing in the library ever sets the flag — blocking is the host's decision, made against the store.
A blocked user creates no conversation document, and is never removed or purged. They can be
put in the trash (TrashAsync), which keeps the block — see Deleting users.
Alongside the flag, UserInfo carries BlockedAt, BlockedBy and BlockedReason, because a
moderation decision nobody can explain is one nobody can review. BlockedBy is free text the
library never resolves or validates — it has no notion of your users, so store whatever you will
want to read in a year: an employee id, an operator name, "automated: spam filter". Blocking is an
ordinary field update through IBotUserStore.UpdateAsync; the port has no method for it.
Clear all four together. The three fields describe the current block, not its history, so lifting a block means writing nulls over them as well.
BlockAuditContractholds any store you write to actually persisting those nulls; keeping the four consistent is the caller's job.
Deleting users
UserInfo is a record, not derived state, so it is never expired on a timer the way conversations
are. What it has instead is a trash can, and the three operations are not three strengths of one
thing:
| Does | Reversible | Refuses when | |
|---|---|---|---|
IBotUserStore.TrashAsync(id) |
Stamps Trashed. Hides the record, changes nothing else, reclaims no space |
Yes | Never, except no such user |
IBotUserStore.RestoreAsync(id) |
Clears Trashed. Takes it back out, whole |
— | The user is not in the trash |
IBotUserStore.DeleteAsync(id) |
Removes the document | No | The user is Blocked |
All three return false rather than throwing when they decline, or when there is no such user.
The trash is for an account that should stop being seen but might come back — a mistake, a departure, a pause. It blanks nothing, deliberately: a store that "helpfully" cleared the personal fields here would destroy the only property that makes the operation worth having.
Erasing a user's data is DeleteAsync, or the purge, which removes the row and the conversation
with it. If a data policy says a record should not exist, that is the path. The purge is what
empties the trash — a trashed account becomes unrecoverable once its stamp is older than the
threshold the host passes.
The trash empties itself, too. A trashed account's next message reaches
InsertAsync, which resurrects the document rather than failing on a duplicate id — so somebody who was trashed and then messages the bot is simply back. That cannot be avoided:UserInfo.IDis the primary key, so the alternatives are restore or throw on every message for ever. What it can be is visible, andInsertAsyncstampsUserInfo.AutoRestoredAtwhen it happens whileRestoreAsyncdeliberately does not. An administrative list can then distinguish an account somebody put back from one that climbed out. Trashing again clears the stamp.If you need someone genuinely kept out, that is
Blocked, not the trash.
A blocked user is never removed. Removing the record removes the block with it, and that account's next message inserts a fresh
UserInfowithBlocked = false. Trashing is fine, and keeps the block: the document survives andInsertAsyncrestores it rather than re-creating it.The consequence, stated rather than buried: a blocked account cannot be erased without unblocking it first. That is a deliberate trade — appropriate where a blocked account is an unwanted outsider rather than a user with a claim on you. Weigh it yourself if that is not your situation.
The trash is reversible by the user as well as the operator. GetAsync hides a trashed record, so
the next message from that account reaches InsertAsync — which restores the existing document rather
than inserting a second one under the same id, and stamps AutoRestoredAt to say so. Any store you
write must do this: without it, trashing a user makes every later message from them fail on a
duplicate key, permanently.
IBotAdministration.PurgeUsersAsync(idleFor, keep) sweeps. The rule is UserPurgePolicy.ShouldPurge —
one place, storage-free, and now the only place: the sweep reads every user through
IBotUserStore.ListAsync and asks the policy about each, rather than narrowing with a query that
would restate the rule somewhere nobody tests:
- never a blocked user, never one part-way through a dialog;
- a user who asked to be forgotten goes once their
Deletedstamp is older than the cutoff; - otherwise they go if untouched since the cutoff and they have no nickname.
The optional keep callback is how a host protects records it has attached its own meaning to —
Tempuro (internal) spares any Telegram account linked to an employee — without the library learning what that
meaning is. There is no timer: the host schedules the sweep.
Administering a bot
IBotAdministration is everything a host needs to run a bot without opening the database. It is
registered by AddBotEngine — unlike a store it has no storage-specific implementation to choose
between, because it is composed from the ports you already registered.
public class BotAdminController(IBotAdministration bot) {
public async Task<IActionResult> Index(string? search) {
var filter = new BotUserFilter { Search = search, Trashed = false };
var users = await bot.ListUsersAsync(filter, skip: 0, take: 25);
var total = await bot.CountUsersAsync(filter);
...
}
public Task Block(string id) => bot.BlockAsync(id, by: CurrentEmployee.Id, reason: "Repeated abuse");
}
BlockAsync(id, by, reason) / UnblockAsync(id) |
The bot ignores a blocked account in silence. Unblocking clears the audit trail with the flag |
TrashAsync(id) / RestoreAsync(id) |
Reversible. Nothing is destroyed |
DeleteAsync(id) |
Not reversible. Removes the record and the conversation. Refuses a blocked account |
ListUsersAsync(filter, skip, take) / CountUsersAsync(filter) |
Paged, ordered by account id. Includes trashed accounts unless the filter excludes them |
GetUserAsync(id) |
One account, trashed or not |
GetConversationAsync(userId) |
What they are stuck in — DialogKey and DialogStep |
PurgeUsersAsync(idleFor, keep) |
Empties the trash and reclaims quiet accounts. Not reversible |
GetSettingsAsync() / SaveSettingsAsync(settings) |
See Settings |
Every method returns false rather than throwing when nothing happened — no such account, or a blocked one refusing to be deleted. That is the same convention the stores use.
BotUserFilter is a small record — a text fragment, Blocked, Trashed — and deliberately not a
query language. No IQueryable, no expression trees, no sort DSL: a port that can be implemented over
a dictionary in twenty lines is one a second host will actually implement.
Three warnings worth putting on your own screens, because this is where somebody acts on them:
- Deleting is permanent, and it is refused on a blocked account — removing the row removes the block, and their next message would re-create the account unblocked. Unblock first if you mean it, knowing that re-admits them meanwhile.
- The trash does not keep anyone out. It hides the record; their own next message takes them back out of it. If you need someone kept out, that is
BlockAsync.- An account can come back on its own, and
UserInfo.AutoRestoredAtsays when. Show it, or an operator watches something they trashed quietly disappear from the trash.
No UI ships with the library, and no ASP.NET dependency comes with it — being free of one is what
makes the core hostable anywhere. Tempuro.Web/Areas/Bot/Controllers/BotUsersController.cs (internal) is a
complete worked example to read: a list, a detail page, and the six actions, each a thin call onto the
service above.
Identity is the host's job
UserInfo is the bot's record of a Telegram account. It is not your application's user. If your
bot needs to act as a known employee/customer/account, resolve that in your dispatcher, before the
bot runs — implement IBotUpdateDispatcher yourself instead of using BotUpdateDispatcher<T>:
public class MyDispatcher(IServiceScopeFactory scopeFactory) : IBotUpdateDispatcher {
public async Task DispatchAsync(Update update, CancellationToken cancellationToken = default) {
using var scope = scopeFactory.CreateScope();
var telegramUserId = update.Message?.From?.Id ?? update.CallbackQuery?.From.Id;
var user = await resolveYourUserAsync(scope, telegramUserId);
scope.ServiceProvider.GetRequiredService<ISessionService>().SetCurrentUser(user); // your abstraction
await scope.ServiceProvider.GetRequiredService<MyBot>().ProcessAsync(update, cancellationToken);
}
}
Dialogs then take your session service as a constructor dependency like any other service.
The example above is complete; Tempuro.Bot.Server/TempuroBotDispatcher.cs (internal) is the same
thing in production, with a pairing flow on top.
8. Transports
The library has no receive loop. Both transports end in the same call:
IBotUpdateDispatcher.DispatchAsync(update, ct).
| Long polling | Webhook | |
|---|---|---|
| Use for | Development | Production |
| Needs | Nothing | A public HTTPS endpoint |
| How | client.ReceiveAsync(...) in a BackgroundService |
An HTTP POST endpoint |
They are mutually exclusive — Telegram refuses getUpdates while a webhook is registered, so
call DeleteWebhook before polling. Two processes polling one token gives HTTP 409.
For a webhook endpoint, four things matter:
- Allow anonymous. Telegram cannot sign in to your application.
- Verify the
X-Telegram-Bot-Api-Secret-Tokenheader against the secret you passed toSetWebhook, using a fixed-time comparison. This is the only thing authenticating the caller. - Deserialize with
JsonBotAPI.Options. Telegram.Bot's types need its own serializer settings. - Return 200 even when processing fails. Telegram retries any non-2xx, so a bug that fails deterministically for one update would be redelivered forever.
Tempuro.Web/Areas/Bot/ (internal) has a production implementation of both.
9. Concurrency
A webhook can deliver two updates for the same chat at once. A turn is a read-modify-write over one conversation document, so overlapping turns would lose state or advance the dialog twice.
ChatLockProvider serialises turns per chat, using 64 fixed stripes rather than one lock per chat —
any stranger can message a bot, so a per-chat dictionary would grow without bound from untrusted
input. Two chats sharing a stripe wait for each other, which costs a little throughput and is
otherwise harmless.
This guards one process only. It is not a distributed lock, so it assumes a single instance owns the bot token — which is what Telegram requires anyway. If you scale out, see Known limitations.
10. Logging and errors
Logging is Microsoft.Extensions.Logging. Register an ILoggerFactory and you get engine and dialog
logs attributed to the concrete type; register nothing and everything is discarded (NullLogger).
The library has no Serilog reference — it was removed precisely so it could be packaged.
Exceptions
| Exception | Meaning |
|---|---|
DialogExecutionException |
Business failure inside a dialog. Its message is shown to the user — write it accordingly. Sent as plain text, so no markup, and quoting untrusted input is safe |
DialogConfigurationException |
A dialog is built or wired wrongly: an unregistered key, a duplicate or missing step name, a second finalizer, or persisted state that deserializes to null. A bug, not user input |
BotSecurityException |
The host refused on authorization grounds. Its message is never shown — the user gets a fixed refusal and the message goes to the log, so it is safe to write it for a developer |
What happens when a turn throws
- The dialog is cancelled first, before anything else. Telling the user is itself a network call that can fail; if that threw while the dialog was still marked live, the conversation would be left believing it was mid-dialog and the user's next message would be swallowed by a dialog that had already died.
- The cause is logged before it is reported, so a failure to deliver the apology cannot erase the only record of what went wrong.
- The user gets a message — once. A step must let its exception propagate rather than reporting it and rethrowing, or the user sees the same text twice.
- Whatever is dirty is still saved.
What the user is told
The exception's type decides it, and both report sites — Dialog.Process and BotController — run
the same code, so they cannot answer it differently.
| Exception | User sees | Logged at |
|---|---|---|
DialogExecutionException |
Its message, verbatim. This is what the type is for | Information |
BotSecurityException |
"You are not allowed to do that." — a fixed line, never the thrown message, and no reference code | Warning |
| Anything else | "Something went wrong. If you report this, quote: a1b2c3d4" | Error |
The reference code is eight hex characters, and the same code appears in the log entry for that failure — that is the point of it. It is generated before the log call, so a failure whose apology never reached the user still leaves something to correlate against.
DialogExecutionException is logged at Information rather than Error because it is the designed way a
dialog refuses, not a fault; treating it as one buries the failures that are faults.
BotOptions.VerboseErrors puts the exception's type and message back into the user's message, with
the code still appended. It is a development convenience — anyone can message a bot, so in production
this hands a stranger type names, and potentially a connection string or a fragment of SQL out of a
driver exception. It does not affect an authorization refusal, whose message is never shown.
Changing what the engine says
The messages above are the engine's own, and all eight of them come from IBotTextProvider — the
three here, plus CancelCommand's two replies, DebugCommand's, the "conversation ended" line and the
refusal a tapped inline button gets. Everything a dialog says is your text in your code and never
passes through it.
public class DanishBotText : DefaultBotTextProvider {
public override string NotAllowed(BotTextContext context) => "Det må du ikke.";
}
builder.Services.AddSingleton<IBotTextProvider, DanishBotText>(); // BEFORE AddBotEngine
builder.Services.AddBotEngine(client, options => { … }, dialogs => …);
AddBotEngine registers the English default with TryAdd, so a registration of your own made first
wins. Derive from DefaultBotTextProvider rather than implementing the interface: every member is
virtual, so you override the one line you care about, and a member added in a later version answers
sensibly instead of breaking your build.
Every member takes a
BotTextContext, and the shipped provider ignores it. It carries the user'sLanguageCode, which Telegram sends on every update. The library speaks one language at a time and that is usually right for an internal tool — but a provider that switches on the code is an ordinary thing to write, and nothing in the engine has to change for it. The context is there so that stays true without a breaking change to this interface.
Authorization is the host's
The library has authentication — the dispatcher resolves who is typing — but no authorization
model, and should not grow one. BotSecurityException is the seam: a host decides someone may not
do something and throws it, and the engine turns that into a refusal the user can read while the
detail goes to the log.
Translate at the throw site. A host's own permission exception cannot be caught further out: a
failure raised inside a step is handled by Dialog.Process, which never rethrows, so nothing above it
— not BotController, not your dispatcher — ever sees it. Tempuro.Bot.Server/BotPermissions.cs
(internal) is a
worked example: a one-line guard the dialogs call instead of the host's own permission check.
A conversation whose dialog can no longer be built — you renamed a key, or removed a dialog — is logged and reset rather than left stuck.
11. Testing
Dialogs are unit-testable because they depend on IBotMessenger, not TelegramBotClient. Substitute
a fake that records what was said, and no token or network is needed.
public class FakeBotMessenger : IBotMessenger {
public List<string> Texts { get; } = [];
public Task<Message> SendMessage(ChatId chatId, string text, ParseMode parseMode = ParseMode.None,
ReplyMarkup? replyMarkup = null, CancellationToken ct = default) {
Texts.Add(text);
return Task.FromResult(new Message { Id = 1, Text = text, Chat = new Chat { Id = chatId.Identifier ?? 0 } });
}
// EditMessageText likewise
}
Two levels, and the difference matters:
- Dialog level — construct the dialog and call
Process(message, userInfo)repeatedly. Good for step logic and branching. It does not go throughBotController, so it cannot test routing, commands, persistence or resumption. - Engine level — wire a real
ServiceProviderwith in-memory stores and driveProcessAsync. This is the only level at which/cancel, resumption and dirty tracking are real.
BotHarness, in Bot.Library.Tests/Fakes/TestBot.cs, is a ready-made engine-level harness — copy it.
Note that it creates a scope per update, exactly as a webhook does; without that, resume tests pass
without ever exercising serialization.
When faking a store, make
GetAsyncreturn a round-tripped copy, not the instance you were handed. An in-memory store that returns the same object keeps[BsonIgnore]fields such asActualDialogalive, so the resume path never runs and the test passes for the wrong reason. This mistake has already been made here once.
12. Rules that are easy to get wrong
A checklist. Each of these has caused a real bug.
| Rule | Why |
|---|---|
Step<T> takes Func<T>, never T |
Resuming replaces the state object; a captured instance loses every earlier turn |
Stores return MakeClean()ed documents |
Deserialization sets the dirty flag; otherwise every turn rewrites everything |
| Dialog keys are persisted data | Rename the class freely; changing the key orphans conversations mid-dialog |
BotId is persisted data |
Changing it hides every existing conversation |
ConversationRetention is persisted data |
Shortening it deletes conversations older than the new window, on the next startup |
| Backfill timestamps before enabling retention | Documents predating store-level stamping carry 0001-01-01, which every TTL window is past |
A store's InsertAsync must restore a trashed user |
The id is the primary key; otherwise every later message from them is a duplicate key |
| Never delete a blocked user | The block goes with the record, and their next message re-creates them unblocked |
| Register the bot controller and dispatcher yourself | AddBotEngine deliberately does not |
Build the provider with ValidateScopes = true |
Root-resolved dialogs are reused across turns and mask resume bugs |
Register CancelCommand |
Otherwise there is no way out of a half-finished dialog |
await your finalizer's work |
The engine awaits the finalizer; if you fire-and-forget inside it, the dialog completes while the write is in flight |
| Let a step's exception propagate; don't report and rethrow | Dialog.Process reports every failure already, so doing both sends the user the same text twice |
Routing must accept /word if the BotFather menu offers it |
TrySimpleText(["help"]) does not match /help |
| One process per bot token | The chat lock is in-process, and Telegram returns 409 for concurrent pollers |
13. API reference
BotController (abstract — your bot derives from this)
| Member | |
|---|---|
Task<string?> FindDialogKeyAsync(Message, UserInfo) |
abstract. Which dialog handles this? Return a registered key or null. Async so routing may query a database |
Task FallBack(Message) |
abstract. No dialog claimed the message |
Task GenericCallback(CallbackQuery) |
virtual. Callback query with no dialog active |
void LogMessage(Message) |
virtual. Trace dump of an unhandled message |
Dictionary<string, Command> Commands |
Populate in your constructor. Keys omit the / |
IBotMessenger Bot / ILogger L |
protected |
string BotId / string BotName |
From BotOptions |
Task ProcessAsync(Update, CancellationToken) |
Entry point. Safe to call concurrently |
Dialog / ResumeDialog<T>
| Member | |
|---|---|
AddStep(IStep) |
Registration order defines MoveToNextStep |
SetFinalizer(IFinalizer) |
At most one |
IBotMessenger Bot, ILogger Logger, string Name, string Key, DialogState State, int CurrentStep |
|
static bool TrySimpleText(Message, string[], bool caseInsensitive = true) |
Matches the first word |
static bool TryRegexText(Message, string pattern, bool caseInsensitive = true) |
|
T StateData (ResumeDialog only) |
Persisted as JSON between turns |
Step / Step<T> / Finalizer / Finalizer<T>
| Member | |
|---|---|
Task<IStepResult> Process(TurnData) |
abstract |
Task<IStepResult> Prompt(TurnData) |
virtual; throws unless overridden |
IBotMessenger Bot, ILogger Logger, string Name, string DialogName |
Assigned by AddStep/SetFinalizer |
AddClean(Message) + CleanUp() |
Queue sent messages to have their keyboards stripped when the dialog ends |
T StateData (generic variants) |
Via the Func<T> accessor |
Task Process(TurnData) (Finalizer) |
abstract, returns no result |
Ports
| Interface | |
|---|---|
IBotMessenger |
SendMessage, EditMessageText. Names mirror Telegram.Bot's extension methods. Outbound only |
IBotFiles |
DownloadAsync(fileId, destination, ct) and DownloadAsync(fileId, ct) → a MemoryStream positioned at zero. Plus the MaxDownloadSize constant (20 MB), which it exposes rather than enforces. See Receiving a file |
IConversationStore |
GetAsync(botId, userId), InsertAsync, UpdateAsync, DeleteAsync |
IBotUserStore |
GetAsync(id), GetIncludingTrashedAsync(id), InsertAsync, UpdateAsync, TrashAsync, RestoreAsync, DeleteAsync, ListAsync(filter, skip, take), CountAsync(filter). Must exclude trashed users from GetAsync — and include them everywhere else — must restore one from InsertAsync, and must order listings by id |
IBotAdministration |
BlockAsync/UnblockAsync, TrashAsync/RestoreAsync, DeleteAsync, ListUsersAsync/CountUsersAsync/GetUserAsync, GetConversationAsync, PurgeUsersAsync(idleFor, keep). Not a store — registered by AddBotEngine and composed from the three above |
IBotSettingsStore |
GetAsync(botId) — null when a bot has none yet, not an empty record — and SaveAsync |
IBotTextProvider |
StaleDialogReset, Fault, NotAllowed, ConversationEnded, CallbackNotSupported, NothingToCancel, DialogCancelled, Debug. Everything the engine says; a dialog's own words are the host's and never pass through here. Every member takes a BotTextContext carrying the user's language. Registered by AddBotEngine with TryAdd, so your own registration wins |
IDialogFactory |
Create(key), IsRegistered(key) |
IBotUpdateDispatcher |
DispatchAsync(update, ct) — implement this to inject host identity |
Infrastructure
| Type | |
|---|---|
BotDependencies |
Bundle passed to BotController. Scoped |
BotOptions |
BotId, BotName, ConversationRetention, DialogTimeout, VerboseErrors |
MongoIndexes |
EnsureBotIndexesAsync(options) — declare the bot collections' indexes. Call at startup |
BotMaintenance |
BackfillBotTimestampsAsync() — one-off, for databases predating store-level timestamps. MigrateBotTrashedFieldAsync() — one-off, run this on any database written before 2026-08-05; without it every trashed account reads back as active, with no error to notice. See Persistence |
DialogRegistry |
Key → type. Singleton |
DialogFactory |
Resolves from the container; stamps Key and Logger |
BotUpdateDispatcher<TController> |
Scope-per-update dispatcher. Singleton |
ChatLockProvider |
64-stripe per-chat lock. Singleton |
Models
| Type | |
|---|---|
UserInfo |
Telegram identity: names, username, language, NickName, Blocked + BlockedAt/BlockedBy/BlockedReason, IsDirty/MakeClean() |
ConversationState |
BotId, UserId, ChatId, DialogKey, DialogStep, StateData, plus SetDialog/RemoveDialog/ForceClearDialog |
BotSettings |
PurgeUsersIdleFor, DialogTimeout. Typed, and only what is safe to change at runtime |
TurnData |
UserInfo + Message for one turn |
Extensions (Bot.Library.Extensions)
| Method | |
|---|---|
Message.GetText(bool lowered = false) |
Empty string for non-text messages — never null |
Message.TryGetAttachment(out BotAttachment?) |
The document or photo the message carried. Resolves a photo to its largest size. Not video, audio, voice or stickers |
Message.AsYesOrNoAnswer() |
YesOrNo.Yes/No/Unknown. Understands English and Danish |
Message.YesOrNoReplyMarkup() |
A Yes/No keyboard |
Showing the top 20 packages that depend on Lds.Bot.Library.
| Packages | Downloads |
|---|---|
|
Lds.Bot.Library.Mongo
MongoDB storage for Lds.Bot.Library. Implements the engine's three persistence ports —
IConversationStore, IBotUserStore and IBotSettingsStore — over MongoDB.Driver, along with the
index declarations and the two one-off migrations. Take it if MongoDB is what you have; the
engine itself names no store.
|
69 |
|
Lds.Bot.Library.Mongo
MongoDB storage for Lds.Bot.Library. Implements the engine's three persistence ports —
IConversationStore, IBotUserStore and IBotSettingsStore — over MongoDB.Driver, along with the
index declarations and the two one-off migrations. Take it if MongoDB is what you have; the
engine itself names no store.
|
12 |
|
Lds.Bot.Library.Mongo
MongoDB storage for Lds.Bot.Library. Implements the engine's three persistence ports —
IConversationStore, IBotUserStore and IBotSettingsStore — over MongoDB.Driver, along with the
index declarations and the two one-off migrations. Take it if MongoDB is what you have; the
engine itself names no store.
|
6 |
|
Lds.Bot.Library.Mongo
MongoDB storage for Lds.Bot.Library. Implements the engine's three persistence ports —
IConversationStore, IBotUserStore and IBotSettingsStore — over MongoDB.Driver, along with the
index declarations and the two one-off migrations. Take it if MongoDB is what you have; the
engine itself names no store.
|
4 |
1.1.0 — first stable release. The engine has been running in production inside Tempuro throughout the pre-release line; the suffix came off once a host had consumed it as a package rather than as a project reference, which is what tested the API from outside. There was no 1.0.0.
SINCE 1.0.0-preview.1:
- IBotFiles and Message.TryGetAttachment — read a file a user sent, without reaching around the
library to ITelegramBotClient. Documents and photos; a photo resolves to its largest size.
- CancellationToken flows through Command.Execute.
- The unused callback prototypes (DialogExtensions, CallbackData) were removed. Nothing called
them, and their design encoded a dialog's Name where it needed its Key.
- readme.md now carries a Known limitations section, and AGENTS.md ships beside it — the
package cache is the only documentation a consumer outside Datalogik can reach.
KNOWN LIMITATIONS worth reading before you design: inline keyboards are not really supported
(reply keyboards only), the bot sends text only, the per-chat lock is in-process so one process
per bot token, and only Message and CallbackQuery updates are handled. See readme.md.
UPGRADING AN EXISTING DATABASE: run BotMaintenance.MigrateBotTrashedFieldAsync() once, from
Lds.Bot.Library.Mongo. The user record's trash stamp moved from `Deleted` to `Trashed`, and an
unmigrated stamp is silently ignored — every trashed account reads back as active, with no error
to notice. The migration is a single $rename and is idempotent.
.NET 10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Telegram.Bot (>= 22.10.2.1)
| Version | Downloads | Last updated |
|---|---|---|
| 1.1.1 | 13 | 8/6/2026 |
| 1.1.0 | 70 | 8/6/2026 |
| 1.0.0-preview.2 | 8 | 8/6/2026 |
| 1.0.0-preview.1 | 5 | 8/5/2026 |