Lds.Bot.Library 1.0.0-preview.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.

  • Known issues and planned work: roadmap.md
  • A complete real implementation to read alongside this: ../Tempuro.Bot.Server/

Contents

  1. What it does and does not do
  2. The model
  3. Quick start
  4. Dialogs and steps
  5. Carrying state across turns
  6. Commands
  7. Persistence — including administration and settings
  8. Transports
  9. Concurrency
  10. Logging and errors
  11. Testing
  12. Rules that are easy to get wrong
  13. 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, media, payments, or (usefully) callback queries — see roadmap.md

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.

  • Process handles the message that arrived while this step was current. Mandatory.
  • Prompt runs 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> — has StateData of type T, serialized to JSON (System.Text.Json) and stored on the conversation between turns. T must 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.


5. Carrying state across turns

ResumeDialog<T> handles the plumbing, but there is one rule you must follow:

Step<T> and Finalizer<T> take a Func<T> accessor, not a T.

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 collMod adjusts 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 stamped LastModified carry 0001-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 MigrateBotTrashedFieldAsync once. The user record's trash stamp used to be stored as Deleted and is now Trashed. The class maps tolerate unknown elements — they have to, or a document written by any other version of this package would throw — so an unmigrated Deleted is 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 BotOptions values 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 (roadmap L2), 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 AutoRestoredAtRestoreAsync 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 roadmap.md.

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. BlockAuditContract holds 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.ID is the primary key, so the alternatives are restore or throw on every message for ever. What it can be is visible, and InsertAsync stamps UserInfo.AutoRestoredAt when it happens while RestoreAsync deliberately 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 UserInfo with Blocked = false. Trashing is fine, and keeps the block: the document survives and InsertAsync restores 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 Deleted stamp 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 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.AutoRestoredAt says 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 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. Tempuro.Bot.Server/TempuroBotDispatcher.cs is a working example.


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:

  1. Allow anonymous. Telegram cannot sign in to your application.
  2. Verify the X-Telegram-Bot-Api-Secret-Token header against the secret you passed to SetWebhook, using a fixed-time comparison. This is the only thing authenticating the caller.
  3. Deserialize with JsonBotAPI.Options. Telegram.Bot's types need its own serializer settings.
  4. 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/ 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 roadmap.md.


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

  1. 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.
  2. The cause is logged before it is reported, so a failure to deliver the apology cannot erase the only record of what went wrong.
  3. 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.
  4. 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's LanguageCode, 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 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 through BotController, so it cannot test routing, commands, persistence or resumption.
  • Engine level — wire a real ServiceProvider with in-memory stores and drive ProcessAsync. 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 GetAsync return a round-tripped copy, not the instance you were handed. An in-memory store that returns the same object keeps [BsonIgnore] fields such as ActualDialog alive, 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
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.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.
13
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

First package release of an engine that has been running in production inside Tempuro.

  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.

Version Downloads Last updated
1.1.1 14 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