Simple but fast. Telegram bot on the knee

In the modern world, telegram bots have become an integral part of our daily lives. They have become indispensable assistants in a wide variety of tasks – from automating everyday operations to providing customers with high-quality service.

Sometimes it happens that there is a need to create your own telegram bot. This moment touched me too. Having started looking for ready-made solutions, I encountered a little disappointment: it turned out that there were no suitable frameworks, and I would have to develop a bot from scratch.

Not scary! In this article, I will share with you all the steps of creating your own framework for a telegram bot using C#.

To begin with, I’ll tell you what I wanted from a telegram bot. Due to the specifics of the project, we had to choose a long-polling model instead of webhooks. The bot must be developed on the platform asp.net. We needed the ability to easily configure, add metrics or cache data. The bot will perform the tasks of updating and reading information.

First, let's follow the path of the unsuspecting developer and try to find ready-made solutions.

Spoiler: writing bots in C# for some reason is not the most popular and favorable direction.

Let's take the library as a reference point Telegram.Bot, as the most popular and developed library for interacting with the telegram bot API. Let's go to github and look for who uses it and how.

Surprise, but for some reason C# is not the best choice for writing telegram bots. The only serious project I found was some strange game for telegram tgwerewolf github, but actively developing. This project uses pure Telegram.Bot, of course you can take something for yourself, but it will be difficult to transfer.

Things are a little better with frameworks. I found 2 ready-made solutions.
Let's start with TelegramBotFramework — we started for health, and ended for peace.

The project is in a sluggish state, although it began vigorously: it is now supported by one person. On the plus side, if you strictly follow the instructions and don’t write something complicated with further expansion of the capabilities of the framework itself, and you are ready to put up with the lack of dependency injection, then it should go very well. There's even a set of “ui” elements, which is fun.

In fact, there is dependency injection, but it is a crutch and it is better not to poke it with a stick, only if you are careful.

Well, one of the minuses: it was difficult to adapt to the existing ecosystem asp.net; no dependency injection; hard to customize. In general, it’s definitely not my choice, so let’s move on.

And finally the most delicious thing – TgBotFramework — a small but interesting framework from the contributor Telegram.Bot. The framework is well written, is friendly with dependency injection, and has a convenient description of commands for the telegram bot. Well, just a fairy tale, let’s go and write a telegram bot.

Alas, for my case there was already ready-made code that caches and takes metrics, and all this on the mediator, but I didn’t want to duplicate the logic. It was also not entirely clear how to control the life cycle of a command handler from a telegram bot, maybe somehow through the implementation of your Processor, but I didn’t try.

It's a shame that C# and the .NET ecosystem are not used in any way to write telegram bots. If you look at other languages ​​- python-telegram-bot And Telethongo – telegram-bot-api and the most interesting implementation in rust— teloxide

Although maybe I didn't search well. I would be glad if you could tell me if there are any other implementations of frameworks for telegram bots in C#.

Well, since I’m so picky about my choice, we don’t despair – put our hands on our feet and make our decision out of crutches. As you might have already guessed, a mediator will be used as a basis. The most important thing when registering command handlers is to declare them as scoped.

First, let's write a background service that will launch a message handler from the bot:

public class PollingServiceBase : BackgroundService
{
    private readonly ITelegramBotClient botClient;
    private readonly IUpdateHandler handler;

    private readonly ReceiverOptions receiverOptions = new()
    {
        AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery],
        ThrowPendingUpdates = true,
    };

    public PollingServiceBase(ITelegramBotClient botClient, IUpdateHandler handler, IOptions<AcadeMarketConfiguration> options, ILogger<PollingServiceBase> logger)
    {
        this.botClient = botClient;
        this.handler = handler;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
	        try
	        {
		        await botClient.ReceiveAsync(updateHandler: handler, receiverOptions: receiverOptions, cancellationToken: stoppingToken);
	        }
	        catch
	        {
	        }
        }
    }

Everything is simple here. We rotate message processing in the background in a loop. ReceiveAsync, or more precisely DefaultUpdateReceiver, has its own loop for sending messages, but its own while will be useful later for recovering from errors in our code, as well as error handling itself. Let's move on to UpdateHandler.

This is where all the magic begins!

public class UpdateHandler(IServiceProvider serviceProvider) : IUpdateHandler, IDisposable
{
    internal readonly Dictionary<long, (string command, IServiceScope scope)> UsersCommand = [];

    public UpdateHandler(
        Dictionary<long, (string command, IServiceScope scope)> dictionary,
        IServiceProvider serviceProvider) : 
        this(serviceProvider)
    {
        UsersCommand = dictionary;
    }

    public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
    {
        await using var scope = serviceProvider.CreateAsyncScope();
        var publisher = scope.ServiceProvider.GetRequiredService<IPublisher>();

        var chat = update.GetChat();
        ArgumentNullException.ThrowIfNull(chat);

        var getCommand = new GetBotCommandNotification { Update = update };
        await publisher.Publish(getCommand, cancellationToken);

        var tuple = ChooseCommand(chat.Id, getCommand.Command);
        if (!tuple.HasValue) return;

        var provider = tuple.Value.scope;
        var command = tuple.Value.command;

        var scopeMediator = provider.ServiceProvider.GetRequiredKeyedService<IMediator>(nameof(CustomTelegramMediator));
        IBotCommandContext updateReceived = new BotCommandContext(update, chat, command);

        try
        {
            await scopeMediator.Publish(updateReceived, cancellationToken);            		
            ArgumentException.ThrowIfNullOrWhiteSpace(updateReceived.Command);
        }
        catch (Exception e)
        {
            var asyncServiceScope = new AsyncServiceScope(provider);
            await asyncServiceScope.DisposeAsync();

            UsersCommand.Remove(chat.Id);

            throw;
        }

        if (updateReceived.NeedRefresh)
        {
            provider.Dispose();
            UsersCommand.Remove(chat.Id);
        }
    }

    private (IServiceScope scope, string command)? ChooseCommand(long chatId, string botCommand)
    {
        var exist = UsersCommand.TryGetValue(chatId, out var tuple);

        IServiceScope provider;
        var command = botCommand;

        if (string.IsNullOrWhiteSpace(command) && !exist) return null;
        if (!string.IsNullOrWhiteSpace(command) && !exist)
        {
            provider = serviceProvider.CreateScope();
            UsersCommand.Add(chatId, (command, provider));
        }
        else if (string.IsNullOrWhiteSpace(command) && exist)
        {
            provider = tuple.scope;
            command = tuple.command;
        }
        else
        {
            if (tuple.command != command)
            {
                tuple.scope.Dispose();

                provider = serviceProvider.CreateScope();
                UsersCommand[chatId] = (command, provider);
            }
            else
            {
                provider = tuple.scope;
                command = tuple.command;
            }
        }

        return (provider, command);
    }
}

There is nothing surprising: for each user we create a scope and the command selected by him. Next we look to see if there are any changes: the user changed the command; the team returned NeedRefresh and so on. The only thing that can confuse me is var scopeMediator = provider.ServiceProvider.GetRequiredKeyedService<IMediator>(nameof(CustomTelegramMediator))but more on that later.

The command is received via GetBotCommandNotificationI don’t know how correct it is that notification returns the result by writing it to GetBotCommandNotification. Myself GetBotCommandHandler very simple:

public abstract class GetBotCommandHandler : INotificationHandler<GetBotCommandNotification>
{
    public abstract UpdateType Type { get; }

    public ValueTask Handle(GetBotCommandNotification command, CancellationToken cancellationToken)
    {
        if (command.Update.Type != Type) return ValueTask.CompletedTask;
        if (!CanHandle(command)) return ValueTask.CompletedTask;

        SetCommand(command);
        return ValueTask.CompletedTask;
    }

    protected internal abstract bool CanHandle(GetBotCommandNotification notification);

    protected internal abstract void SetCommand(GetBotCommandNotification notification);
}

It's time to process the commands. The process is as follows: we generate a notification with the necessary information to process the command from Telegram. Then we send this notification to listeners. If we find a specific listener for the sent command, we begin processing. Otherwise, we skip the notification because we don't have a listener to process it.

However, there is a problem here: conceptually, the notification needs to be processed by many listeners, and we also cannot apply IPipelineBehavior to notifications.

But actually it is not. Mediatr has added the ability to create your own mediator and access to notifications when they are published. In addition, the ability to obtain an object by key has recently been added (AddKeyed and his friends), how well everything turned out. That’s why we go and write our own mediator to process messages.

public class CustomTelegramMediator : MediatR.Mediator
{
    private readonly IServiceProvider serviceProvider;

    public CustomTelegramMediator(IServiceProvider serviceProvider) : base(serviceProvider)
    {
        this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
    }

    public CustomTelegramMediator(IServiceProvider serviceProvider, INotificationPublisher publisher) : base(serviceProvider, publisher)
    {
        this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
    }

    protected override async Task PublishCore(IEnumerable<NotificationHandlerExecutor> handlerExecutors, INotification notification, CancellationToken cancellationToken)
    {
        if (notification is not IBotCommandContext botCommandContext) return;

        var handler = handlerExecutors.Single(handlerExecutor =>
            handlerExecutor.HandlerInstance is TelegramCommandHandler commandHandler &&
            commandHandler.Command.Equals(botCommandContext.Command));

        var wrapperType = typeof(NotificationHandlerWrapperImpl<>).MakeGenericType(notification.GetType());
        var wrapper = Activator.CreateInstance(wrapperType, [handler.HandlerCallback]) ?? throw new InvalidOperationException($"Could not create wrapper type for {notification.GetType()}");
        var handlerBase = (NotificationHandlerBase)wrapper;

        await handlerBase.Handle(notification, serviceProvider, cancellationToken);
    }
}

And yes, that's almost all that remains NotificationHandlerWrapperImpl to process the notification and wrap it in IPipelineBehavior

public class NotificationHandlerWrapperImpl<TRequest>(Func<TRequest, CancellationToken, Task> handlerCallback) : NotificationHandlerBase
    where TRequest : INotification
{
    public Task Handle(TRequest request, IServiceProvider serviceProvider, CancellationToken cancellationToken)
    {
        return serviceProvider
            .GetServices<IPipelineBehavior<TRequest, Unit>>()
            .Reverse()
            .Aggregate((RequestHandlerDelegate<Unit>)Handler,
                (next, pipeline) => () => pipeline.Handle(request, next, cancellationToken))();

        async Task<Unit> Handler()
        {
            await handlerCallback(request, cancellationToken);
            return Unit.Value;
        }
    }

    public override Task
        Handle(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
        Handle((TRequest)request, serviceProvider, cancellationToken);
}

Now let’s describe a contract for notifying about the processing of a command from a telegram bot

public interface IBotCommandContext : INotification
{
    public Update Update { get; }

    public Chat Chat { get; }

    public string Command { get; }

    public Message? Message { get; }

    public IReadOnlyCollection<string>? Args { get; }

    public bool NeedRefresh { get;  }

    public void AddMessage(Message? message);

    public void AddArgs(IEnumerable<string> args);

    public void SetNeedRefresh();
}

Now if we need IPipelineBehavior directly to process the command, we simply indicate where TRequest : IBotCommandContext. For example, we implement saving detailed information about users who use the bot.

public class AddUserBehaviour<TRequest, TResponse>(IDistributedCache cache) : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IBotCommandContext
{
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        var value = await cache.GetStringAsync(request.Chat.Id.ToString(), cancellationToken);
        if (!string.IsNullOrWhiteSpace(value)) return await next();

        await cache.SetAsync(request.Chat.Id.ToString(), Encoding.ASCII.GetBytes(request.Chat.Id.ToString()), cancellationToken);

        return await next();
    }
}

And if you need to connect to an existing IPipelineBehavioradd to the implementation of the contract IBotCommandContext marker. For example, there is the following RequestPerformanceBehaviour with marker IRequestPerformance

public class RequestPerformanceBehaviour<TRequest, TResponse>(
    TimeProvider timeProvider,
    ILogger<RequestPerformanceBehaviour<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequestPerformance
{
    private readonly ILogger<RequestPerformanceBehaviour<TRequest, TResponse>> logger = logger ?? throw new ArgumentNullException(nameof(logger));
    private readonly TimeProvider timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        var start = timeProvider.GetTimestamp();

        var response = await next();

        var diff = timeProvider.GetElapsedTime(start);

        if (diff.TotalMilliseconds < 500) return response;

        var name = typeof(TRequest).Name;

        logger.LongRunningRequest(name, diff, request);

        return response;
    }
}

internal static partial class LoggerExtensions
{
    [LoggerMessage(EventId = 1, EventName = nameof(LongRunningRequest), Level = LogLevel.Warning, Message = "Long Running Request: {Name} ({Elapsed} milliseconds) {Request}")]
    public static partial void LongRunningRequest(this ILogger logger, string name, TimeSpan elapsed, object request);
}

public interface IRequestPerformance;

Add IRequestPerformance for implementation IBotCommandContext and that's all.

Next is the processing of commands, for this we implement a general TelegramCommandHandler

public abstract class TelegramCommandHandler : INotificationHandler<IBotCommandContext>
{
    public abstract string Description { get; }

    public abstract string Command { get; }

    public virtual int Calls { get; protected set; }

    public virtual async Task Handle(IBotCommandContext notification, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(notification.Command)) return;
        if (string.IsNullOrWhiteSpace(Command)) return;
        if (!string.Equals(notification.Command, Command)) return;

        Calls++;

        await Core(notification, cancellationToken).ConfigureAwait(false);

        if (await IsLastStage())
        {
            notification.SetNeedRefresh();
        }
    }

    protected abstract Task Core(IBotCommandContext notification, CancellationToken cancellationToken);

    public virtual Task<bool> IsLastStage() => new(true);
}

Then all that remains is to rivet teams while it’s hot.

Let's add a simple command:

public class TestCommandHandler : TelegramCommandHandler
{
    public override string Description { get; }
    public override string Command => "/hi";

    private readonly ITelegramBotClient botClient;

    public TestCommandHandler(ITelegramBotClient botClient)
    {
        this.botClient = botClient;
    }

    protected override async Task Core(IBotCommandContext notification, CancellationToken cancellationToken)
    {
        await botClient.SendTextMessageAsync(notification.Chat, "Hi", cancellationToken: cancellationToken);
    }
}

Let's add a command with several stages and issue a simple finite state machine:

public class SignupCommandHandler(IMediator mediator, ITelegramBotClient botClient) : TelegramCommandHandler
{
    public const string CommandValue = "/signup";
    public const string DescriptionValue = "Зарегистрироваться";

    public override string Description => DescriptionValue;
    public override string Command => CommandValue;

    private readonly IMediator mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
    private readonly ITelegramBotClient botClient = botClient ?? throw new ArgumentNullException(nameof(botClient));

    private string Firstname;
    private string Surname;
    private string Age;

    protected override Task Core(IBotCommandContext notification, CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(notification.Message?.Text);

        switch (Calls)
        {
            case 2:
                Firstname = notification.Message.Text;
                break;
            case 3:
                Surname = notification.Message.Text;
                break;
            case 4:
                Age = notification.Message.Text;
                break;
        }

        return Calls switch
        {
            1 => EnterFirstname(notification, cancellationToken),
            2 => EnterSurname(notification, cancellationToken),
            3 => EnterAge(notification, cancellationToken),
            4 => AddNewUser(notification, cancellationToken),
            _ => Task.CompletedTask,
        };
    }

    private async Task EnterFirstname(IBotCommandContext notification, CancellationToken cancellationToken)
    {
        const string text = "Пожалуйтса, введите Ваше имя";
        await botClient.SendTextMessageAsync(chatId: notification.Chat, text: text, cancellationToken: cancellationToken);
    }

    private async Task EnterSurname(IBotCommandContext notification, CancellationToken cancellationToken)
    {
        const string text = "Пожалуйтса, введите Вашу фамилию";
        await botClient.SendTextMessageAsync(chatId: notification.Chat, text: text, cancellationToken: cancellationToken);
    }

    private async Task EnterAge(IBotCommandContext notification, CancellationToken cancellationToken)
    {
        const string text = "Пожалуйтса, введите Ваш возраст";
        await botClient.SendTextMessageAsync(chatId: notification.Chat, text: text, cancellationToken: cancellationToken);
    }

    private async Task AddNewUser(IBotCommandContext notification, CancellationToken cancellationToken)
    {
        var text = "Регистрирую.";
        await botClient.SendTextMessageAsync(chatId: notification.Chat, text: text, cancellationToken: cancellationToken);

        text = $"Добро пожаловать! {Firstname} {Surname}";
        await botClient.SendTextMessageAsync(chatId: notification.Chat, text: text, cancellationToken: cancellationToken);
    }

    public override ValueTask<bool> IsLastStage()
    {
        return ValueTask.FromResult(Calls >= 4);
    }
}

This is just the beginning, only imagination and business desires stop us further. For example, you can wind TransactionBehavior to commands that are sent from TelegramCommandHandler, so we will know for sure that everything was successful and we can display the result to the user. The same with caching, metrics, and so on. Well, of course, if your infrastructure is built on a mediator, heh.

At the end there is error handling. The single point of catching errors is PollingServiceBasesince inside ReceiveAsync in case of internal errors it will cause HandlePollingErrorAsync at IUpdateHandler. Because IUpdateHandler has enough information to understand which user experienced the error and write to him about it.

Or you can wrap command handlers in IPipelineBehavior for error handling, because now we can do that.

And such an implementation is easy and pleasant to support and test, since each handler has its own class with logic.

That's all! Of course, the above implementation only superficially covers the processing of commands from a telegram bot. For example, it would be a good idea to add message IDs and delete them if the user chooses a different command.

Or have the ability to restore the user’s context in critical situations. But all this can be easily implemented thanks to the added ability to squeeze into the message processing process and a custom mediator.

An example of this implementation can be found on my github. If you have any suggestions, questions or improvements, feel free to say so, I’ll be glad to read and answer.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *