Telegram Bots in C# and How to Build Them: TeleBotFramework
Denis Stepanov

Denis Stepanov @alados

Location:
Serbia
Joined:
Aug 5, 2025

Telegram Bots in C# and How to Build Them: TeleBotFramework

Publish Date: Aug 6
3 0

Telegram Bots in C# and How to Build Them: TeleBotFramework

I’ve been developing software for over 10 years, mostly in C# and ASP.NET, and one day I decided to figure out what’s the deal with these Telegram bots and how they actually work.

Spoiler: They’re basically just a web server with a webhook, or even a console application with long polling.

I started by looking for common patterns or approaches for building Telegram bots in C#. Surprisingly, I couldn’t really find any consistent approach. Maybe I didn’t look hard enough — but in true developer fashion, I decided to just build my own “bike” anyway. I didn't do it from scratch, I've taken the official C# library as a start and made some adaptations to easy to use.


The Problem with Typical Bot Examples

The first thing that surprised me when checking existing bot examples was the god method approach — a single giant controller method with nested switch statements, if/else blocks, and command logic all over the place.
I immediately knew I wanted a different path.

Something like this (simplified example):

class Program
{
    static TelegramBotClient botClient;

    static void Main(string[] args)
    {
        botClient = new TelegramBotClient("123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11");

        botClient.OnMessage += Bot_OnMessage;
        botClient.StartReceiving();

        Console.WriteLine("Bot started. Press Enter to stop...");
        Console.ReadLine();
        botClient.StopReceiving();
    }

    static void Bot_OnMessage(object sender, MessageEventArgs e)
    {
        var text = e.Message.Text;
        if (text == "/start")
        {
            botClient.SendTextMessageAsync(e.Message.Chat.Id, "Hi! I'm a terrible bot.");
        }
        else
        {
            botClient.SendTextMessageAsync(e.Message.Chat.Id, "I don't know this command :(");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Instead of this, I thought about using the Command pattern, because that’s literally what we’re doing — sending commands through a bot.
To create these commands dynamically, a Factory pattern works well.
And for orchestration, I used the built-in Dependency Injection in .NET, which since .NET 8 supports keyed services (perfect for registering commands by name).


The Command Interface

public interface ITelegramCommand
{
    public Task Execute(UpdateInfo update);
}
Enter fullscreen mode Exit fullscreen mode

The Command Factory

public interface ICommandFactory
{
    ITelegramCommand? CreateCommand(string commandName);
    IList<CommandInfo> GetCommandList();
}

public class CommandFactory(IServiceProvider serviceProvider) : ICommandFactory
{
    private readonly IServiceProvider _serviceProvider = serviceProvider;

    public ITelegramCommand? CreateCommand(string commandName)
    {
        return _serviceProvider.GetKeyedService<ITelegramCommand>(commandName);
    }

    public IList<CommandInfo> GetCommandList()
    {
        return _serviceProvider.GetKeyedServices<ITelegramCommand>(KeyedService.AnyKey)
            .Select(s =>
            {
                var type = s.GetType();
                var command = type.GetCustomAttribute<CommandAttribute>() ?? throw new Exception("ITelegramCommand should have Command attribute");
                if (!command.IsPublic)
                    return null; // Skip non-public commands

                return new CommandInfo
                {
                    Name = command.Name,
                    Description = command.Description
                };
            })
            .Where(x => x is not null)
            .ToList();
    }
}
Enter fullscreen mode Exit fullscreen mode

The Command Attribute

[AttributeUsage(AttributeTargets.Class)]
public class CommandAttribute(string name, string description, bool isPublic = false) : Attribute
{
    public string Name { get; } = name;
    public string Description { get; } = description;
    public bool IsPublic { get; } = isPublic;
}

Enter fullscreen mode Exit fullscreen mode

With this, you can:

  • Create a command instance
  • Get a full list of public commands (useful for /help and auto-generating menus)

Automatic Registration

public static class ModuleExtensions
{
    public static void AddTelegramFramework(this IServiceCollection services, Assembly[] assembliesToRegisterCommandFrom)
    {
        services.AddScoped<ICommandFactory, CommandFactory>();
        services.AddScoped<ITelegramUpdateHandler, TelegramUpdateHandler>();
        services.AddSingleton<IUserSessionManager, UserSessionManager>();
        services.AddScoped<ITeleBotClient, TeleBotClient>();
        services.AutoRegisterCommands([.. assembliesToRegisterCommandFrom, typeof(ModuleExtensions).Assembly]);
        services.AddHostedService<BotStartupHostedService>();
    }

    private static void AutoRegisterCommands(this IServiceCollection services, Assembly[] assemblies)
    {
        var type = typeof(ITelegramCommand);
        var commandTypes = assemblies.SelectMany(s => s.GetTypes()).Where((p) => type.IsAssignableFrom(p) && p != type).ToList();
        var commandList = new List<(string Name, string Description)>();
        foreach (var commandType in commandTypes)
        {
            if (!typeof(ITelegramCommand).IsAssignableFrom(commandType))
                continue;

            var command = commandType.GetCustomAttribute<CommandAttribute>() ?? throw new Exception($"ITelegramCommand should have Command attribute");
            if (command.IsPublic)
                commandList.Add((command.Name, command.Description));

            services.AddKeyedScoped(typeof(ITelegramCommand), command.Name, commandType);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

To auto-register your command, you need to add an attribute to it and just implement ITelegramCommand interface


Handling Updates

public class TelegramUpdateHandler : ITelegramUpdateHandler
{
    private readonly ICommandFactory _commandFactory;
    private readonly IUserSessionManager _userSessionManager;
    private readonly ILogger<TelegramUpdateHandler> _logger;

    public TelegramUpdateHandler(ICommandFactory commandFactory, IUserSessionManager userSessionManager, ILogger<TelegramUpdateHandler> logger)
    {
        _commandFactory = commandFactory;
        _userSessionManager = userSessionManager;
        _logger = logger;
    }

    public async Task HandleUpdate(Update update)
    {

        if (update.Message != null)
        {
            var updateInfo = new UpdateInfo
            {
                UserId = update.Message.From!.Id,
                Username = update.Message.From.Username ?? string.Empty,
                FirstName = update.Message.From.FirstName ?? string.Empty,
                LastName = update.Message.From.LastName ?? string.Empty,
                ChatId = update.Message.Chat.Id,
                MessageId = update.Message.MessageId,
                Text = update.Message.Text,
            };
            if (!string.IsNullOrEmpty(updateInfo.Text) && updateInfo.Text == "/cancel")
            {
                await _commandFactory.CreateCommand("/cancel")!.Execute(updateInfo);
                return;
            }

            var userSession = _userSessionManager.GetSession(updateInfo.UserId);
            string commandName;
            if (userSession is null)
            {
                if (string.IsNullOrWhiteSpace(update.Message.Text))
                {
                    return;
                }

                var commandWithParams = update.Message.Text.Split(' ');
                commandName = commandWithParams[0];
            }
            else
            {
                commandName = userSession.Command;
            }

            var command = _commandFactory.CreateCommand(commandName);
            if (command is null)
            {
                _logger.LogInformation("Unknown command {commandName}", commandName);
                return;
            }

            await command.Execute(updateInfo);
        }

        if (update.CallbackQuery is { } cb)
        {
            var updateInfo = new UpdateInfo
            {
                UserId = update.CallbackQuery.From.Id,
                Username = update.CallbackQuery.From.Username ?? string.Empty,
                FirstName = update.CallbackQuery.From.FirstName ?? string.Empty,
                LastName = update.CallbackQuery.From.LastName ?? string.Empty,
                ChatId = update.CallbackQuery.Message!.Chat.Id,
                MessageId = update.CallbackQuery.Message.Id,
                Text = update.CallbackQuery.Data,
                InlineKeyboardMarkup = update.CallbackQuery.Message.ReplyMarkup,
            };
            if (string.IsNullOrWhiteSpace(updateInfo.Text))
            {
                _logger.LogInformation("empty callback");
                return;
            }

            var commandWithParams = updateInfo.Text.Split(' ');
            var command = _commandFactory.CreateCommand(commandWithParams[0]);
            if (command is null)
            {

                _logger.LogInformation("Unknown command {commandName}", updateInfo.Text);
                return;
            }

            await command.Execute(updateInfo);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

This code could be improved, but it already meets the basic requirements:

  • Create commands from incoming text
  • Handle callbacks
  • Pass a unified UpdateInfo object to commands

User Sessions

For text input beyond simple button clicks, I implemented IUserSessionManager:

public class UserSessionManager : IUserSessionManager
{
    private readonly ConcurrentDictionary<long, UserSession> _sessions = new();

    public UserSession? GetSession(long userId)
    {
        return _sessions.GetValueOrDefault(userId);
    }

    public UserSession CreateOrUpdateSession(long userId, string command, int step, string[]? args = null)
    {
        return _sessions.AddOrUpdate(userId, _ => new UserSession(userId, command, step, args), (key, oldValue) =>
        {
            oldValue.Command = command;
            oldValue.Step = step;
            oldValue.Arguments = args ?? oldValue.Arguments;
            return oldValue;
        });
    }

    public void ClearSession(long userId)
    {
        _sessions.TryRemove(userId, out _);
    }
}

Enter fullscreen mode Exit fullscreen mode

It stores the current state of the user so we can continue multi-step commands.


Another thing that had to be handled it's Rate Limiting. Telegram allow only 1 request per chat and 30 in max from one bot per second. I wrote my own implementation of ITelegramBotClient wrapper that provide opportunities to handle this possible issue.

public class TeleBotClient(ITelegramBotClient botClient) : ITeleBotClient
{
    private readonly ITelegramBotClient _botClient = botClient;
    private int _messagesSentThisSecond = 0;
    private DateTime _lastResetTime = DateTime.UtcNow;

    public async Task SendMessage(long chatId, string message, ParseMode parseMode = ParseMode.None, InlineKeyboardMarkup? replyMarkup = null)
    {
        await SendMessageWithRateLimit(_botClient.SendMessage(chatId, message, parseMode, replyMarkup: replyMarkup), 20);
    }

    public async Task EditMessageText(long chatId, int messageId, string newText, InlineKeyboardMarkup? replyMarkup = null)
    {
        await SendMessageWithRateLimit(_botClient.EditMessageText(chatId, messageId, newText, replyMarkup: replyMarkup), 20);
    }

    public async Task EditMessageReplyMarkup(long chatId, int messageId, InlineKeyboardMarkup? replyMarkup = null)
    {
        await SendMessageWithRateLimit(_botClient.EditMessageReplyMarkup(chatId, messageId, replyMarkup: replyMarkup), 20);
    }

    public async Task DeleteMessage(long chatId, int messageId)
    {
        await SendMessageWithRateLimit(_botClient.DeleteMessage(chatId, messageId), 20);
    }

    private async Task SendMessageWithRateLimit(Task sendMessage, int maxRequestPerSecond = 20)
    {
        lock (this)
        {
            var now = DateTime.UtcNow;
            if ((now - _lastResetTime).TotalSeconds >= 1)
            {
                _messagesSentThisSecond = 0;
                _lastResetTime = now;
            }

            if (_messagesSentThisSecond >= maxRequestPerSecond)
            {
                var waitTime = 1000 - (int)(now - _lastResetTime).TotalMilliseconds;
                if (waitTime > 0)
                    Thread.Sleep(waitTime);

                _messagesSentThisSecond = 0;
                _lastResetTime = DateTime.UtcNow;
            }

            _messagesSentThisSecond++;
        }

        try
        {
            await sendMessage;
        }
        catch (ApiRequestException ex) when (ex.ErrorCode == 429)
        {
            var retryAfter = ex.Parameters?.RetryAfter ?? 1;
            await Task.Delay(retryAfter * 1000);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Example Commands

Simple command:

[Command("/start", "Start operation", true)]
internal class StartCommand(ITeleBotClient bot) : ITelegramCommand
{
    private readonly ITeleBotClient _bot = bot;

    public async Task Execute(UpdateInfo update)
    {
        await _bot.SendMessage(update.ChatId, $"Hello, {update.Username ?? update.FirstName + " " + update.LastName}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Command with state:

[Command(_commandName, "Resend message to chat", true)]
internal class UserStateExampleCommand(ITeleBotClient bot, IUserSessionManager userSessionManager) : ITelegramCommand
{
    private readonly ITeleBotClient _bot = bot;
    private readonly IUserSessionManager _userSessionManager = userSessionManager;
    private const string _commandName = "/resend";

    public async Task Execute(UpdateInfo update)
    {
        var currentSession = _userSessionManager.GetSession(update.UserId);
        var currentStep  = currentSession?.Step ?? 0;
        switch (currentStep)
        {
            case 0:
                await _bot.SendMessage(update.ChatId, "Hi, enter message and I'll resend it to you");
                _userSessionManager.CreateOrUpdateSession(update.UserId, _commandName, 1, ["arg"]);
                break;
            case 1:
                if (string.IsNullOrWhiteSpace(update.Text))
                {
                    await _bot.SendMessage(update.ChatId, "Please enter a valid message.");
                    return;
                }
                await _bot.SendMessage(update.ChatId, $"You entered: {update.Text}. Argument was {currentSession!.Arguments[0]}");
                _userSessionManager.ClearSession(update.UserId);
                break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach

  • Commands are independent and easy to test
  • Auto-generates the bot menu
  • Easy to extend
  • No god methods

📦 TeleBotFramework on GitHub
📦 NuGet Package

I’m sure there are ways to improve the code, but I built this in my free time and I like the result.
In a follow-up post, I’ll show how I used this library for a specific bot.
If you find it useful, give it a ⭐ on GitHub.

Comments 0 total

    Add comment