Я занимаюсь разработкой уже 10 лет, большую часть времени на C# и ASP.Net и взбрело мне в голову разобраться, что такое эти ваши телеграм боты и как они работают.
Спойлер: Это просто вебсервер с webhook или вообще консольное приложения с long-pooling.

И начал я с того, что это такое и есть ли какие-то общие подходы к решению этой задачи. И на удивление, не нашёл. Вполне возможно, что просто плохо искал и решил сделать свой велосипед, но что уж тут поделать, уже сделал.

Первое что меня удивило в примерах кода других ботов, это god метод в контроллере, который имеет несколько свитч-кейсов один в другом, а внутри ещё какие-то условные операторы. Мне сразу показалось это странным и я решил пойти по-другому пути. Типа такого, но пример не настоящий.

 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("Бот запущен. Нажмите Enter для остановки...");
            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, "Привет! Я ужасный бот.");
            }
            else
            {
                botClient.SendTextMessageAsync(e.Message.Chat.Id, "Я не знаю этой команды :(");
            }
        }
    }

Не буду говорить, что я хорош в теории паттернов, но тут мне в голову сразу пришла идея использовать command, так как это буквально то, что мы делаем, отправляем команды через бота. Потом чтобы эти команды как-то создавать вполне подходит фабрика. Ну а для оркестрации всего я решил использовать стандартный DI, который позволяет регистрировать объекты по названию (начиная с .Net 8).

И получился вот такой интерфейс для команды

public interface ITelegramCommand
{
    // Имя с которым команда регистрируется в телеграме
    public static string? Name { get; }
    // Её описание
    public static string? Description { get; }
    // Показывает надо ли эту команду регистрировать в общем списке команд
    public static bool IsPublic => true;
    // Выполнение
    public Task Execute(UpdateInfo update);
}

Можно заметить, что тут статик поля, но это пригодится чуть дальше, для автоматизации процесса.

Дальше идёт интерфейс фабрики и её реализация

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 properties = type.GetProperties(BindingFlags.Public | BindingFlags.Static);
                var isPublic = (bool)properties.First(p => p.Name == nameof(ITelegramCommand.IsPublic)).GetValue(null)!;
                if (!isPublic)
                    return null; // Skip non-public commands

                var name = properties.First(p => p.Name == nameof(ITelegramCommand.Name)).GetValue(null) as string;
                var description = properties.First(p => p.Name == nameof(ITelegramCommand.Description)).GetValue(null) as string;

                return new CommandInfo
                {
                    Name = name!,
                    Description = description!
                };
            })
            .Where(x => x is not null)
            .ToList();
    }
}

Получается, что создания instance'a команды мы передаём её название, а уже Service Provider достаёт нужную команду по имени. Также тут можно получить полный список публичных команд. Это нужно для автоматического их добавление в меню и для команды help.

А дальше ещё осталось настроить автоматическую регистрацию всех компонентов библиотеки, для этого у меня есть вот такое расширение к IServiceCollection.

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.AutoRegisterCommands([.. assembliesToRegisterCommandFrom, typeof(ModuleExtensions).Assembly]);
    }

    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 nameProperty = commandType.GetProperty(nameof(ITelegramCommand.Name), BindingFlags.Public | BindingFlags.Static);
            var name = (string)nameProperty!.GetValue(null)!;

            var descriptionProperty = commandType.GetProperty(nameof(ITelegramCommand.Description), BindingFlags.Public | BindingFlags.Static);
            var description = (string)descriptionProperty!.GetValue(null)!;

            var isPublicProperty = commandType.GetProperty(nameof(ITelegramCommand.IsPublic), BindingFlags.Public | BindingFlags.Static);
            var isPublic = (bool)isPublicProperty!.GetValue(null)!;
            if (isPublic)
                commandList.Add((name, description));

            services.AddKeyedScoped(typeof(ITelegramCommand), name, commandType);
        }

        var sp = services.BuildServiceProvider();
        using var scope = sp.CreateScope();
        var bot = scope.ServiceProvider.GetRequiredService<ITelegramBotClient>();
        bot.SetMyCommands(
            [.. commandList.Select(c =>
                new BotCommand
                {
                    Command = c.Name.TrimStart('/'), // /start → start
                    Description = c.Description
                })
            ]
        );
    }
}

Именно для этого поля делались статичными, чтобы через рефлексию их можно было получить по типу. Получается, что мы регистрируем все команды, которые имплементируют интерфейс ITelegramCommand из Assembly, что мы передали. Плюс стандартные команды — help и cancel.

Теперь осталось только написать Handler сообщений от телеграмма и всё. А вот и он.

public class TelegramUpdateHandler : ITelegramUpdateHandler
{
    private readonly ITelegramBotClient _bot;
    private readonly ICommandFactory _commandFactory;
    private readonly IUserSessionManager _userSessionManager;

    public TelegramUpdateHandler(ICommandFactory commandFactory, ITelegramBotClient bot, IUserSessionManager userSessionManager)
    {
        _commandFactory = commandFactory;
        _bot = bot;
        _userSessionManager = userSessionManager;
    }

    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 == CancelCommand.Name)
            {
                await _commandFactory.CreateCommand(CancelCommand.Name)!.Execute(updateInfo);
                return;
            }

            var userSession = _userSessionManager.GetSession(updateInfo.UserId);
            string commandName;
            if (userSession is null)
            {
                if (string.IsNullOrWhiteSpace(update.Message.Text))
                {
                    await _bot.SendMessage(update.Message.Chat.Id, $"Enter command, please");
                    return;
                }

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

            var command = _commandFactory.CreateCommand(commandName);
            if (command is null)
            {
                await _bot.SendMessage(update.Message.Chat.Id, $"Unknown command: {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))
            {
                await _bot.SendMessage(updateInfo.ChatId, $"empty callback");
                return;
            }

            var commandWithParams = updateInfo.Text.Split(' ');
            var command = _commandFactory.CreateCommand(commandWithParams[0]);
            if (command is null)
            {
                await _bot.SendMessage(updateInfo.ChatId, $"Unknown command: {updateInfo.Text}");
                return;
            }

            await command.Execute(updateInfo);
        }
    }

Этот код мне нравится уже не настолько и я уверен, что тут можно сделать улучшения, но пока оставил в таком виде, оно покрывает все текущие нужды.

Обрабатывая сообщения таким образом мы создаём команды на основе пришедшего текста и также обрабатываем Callback'и, создавая единый объект, из которого в команде достаём мета-информацию о сообщении.

В таком виде уже можно создавать команды и работать с ботами, но есть нюанс взаимодействия с пользователями. Пока мы используем только кнопки, то можно ничего больше не делать, но вот незадача, иногда нам необходим текстовый ввод от пользователя. И тут появляется IUserStateManager, который выше появлялся в коде.

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 _);
    }
}

Суть его совсем не хитрая, он просто хранит текущий State пользователя, и если он есть, то значит отправляем его сообщение в соответствующую команду, а там команда сама разберётся с ним. Так же можно туда передать набор аргументов. Очень удобно, учитывая, что стандартно в Callback можно передать только 64 байта данных.
А ещё всегда можно использовать для хранения состояний какой-нибудь кэш или базу данных, но пока мне было достаточно и словаря.

И вот как выглядит самая простая команда без стейта. Сама зарегистрируется и успешно выполнится, после запроса от пользователя.

internal class StartCommand(ITelegramBotClient bot) : ITelegramCommand
{
    private readonly ITelegramBotClient _bot = bot;

    public static string Name => "/start";
    public static string Description => "Start operation";
    public static bool IsPublic => true;

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

А вот так выглядит команда с использованием стейта.

internal class UserStateExampleCommand(ITelegramBotClient bot, IUserSessionManager userSessionManager) : ITelegramCommand
{
    private readonly ITelegramBotClient _bot = bot;
    private readonly IUserSessionManager _userSessionManager = userSessionManager;

    public static string Name => "/resend";
    public static string Description => "Resend message to chat";
    public static bool IsPublic => true;

    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, Name, 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;
        }
    }
}

Используя этот нехитрый набор, можно очень легко сделать маленького бота, а потом наращивать его функционал. Вот некоторые плюсы:

  • Команды независимы и легко тестируются

  • Можно автоматически строить меню

  • Лёгкое расширение

  • Отсутствие «god‑методов»

Библиотека на GitHub
Nuget

Уверен, что в комментах будет много предложений по улучшению кода, но я писал это дело в свободное время, для себя. Мне нравится результат, который получился.

Через пару дней напишу, как я эту библиотеку применял, потому что писалась то она для одного конкретного бота, но вдруг кому-нибудь тоже пригодиться. Если будете использовать, то поставьте звёздочку.

А большего мне и не надо
А большего мне и не надо

Комментарии (4)


  1. donRumatta
    12.07.2025 10:04

    Спасибо за то, что поделились!

    Запилили на хакатоне бота, его, внезапно, решили развивать - а там уже дикая простыня из if-ов) Как раз недавно слегка погуглил какие-то best practices, там как раз к стейт-машине сводится организация многоуровнего общения. И тут еще ваша статья подоспела)

    Разве что у нас нет четких команд, попытались совместить с ИИ, чтобы общение шло естественным языком.


    1. alados Автор
      12.07.2025 10:04

      Мне кажется, даже с ИИ можно использовать подход команд, но это надо подумать, так из головы решения нет)


  1. Marsezi
    12.07.2025 10:04

    Я посмотрел вашу модель пользователя и там практически вся информация есть о пользователи , это какие-то запрашиваемые права на раскрытие данных при авторизации пользователя или через ботов тупо телега сливает всю инфу о акке пользователя?


    1. alados Автор
      12.07.2025 10:04

      Телега на каждый апдейт отдает эти данные, если они не закрыты настройками приватности самого пользователя