Просто, но быстро. Телеграм бот на коленке

Kate

Administrator
Команда форума
В современном мире телеграм-боты стали неотъемлемой частью нашей повседневной жизни. Они стали незаменимыми помощниками в самых разнообразных задачах – от автоматизации повседневных операций до обеспечения клиентов высококачественным сервисом.

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

Не страшно! В этой статье я поделюсь с вами всеми этапами создания собственного фреймворка для телеграм-бота с использованием C#.

Для начал я расскажу, что я хотел от телеграм бота. Из-за особенностей проекта пришлось выбрать long-polling модель вместо webhooks. Бот должен быть разработан на платформе asp.net. Нужны были возможности легко настраивать, добавления метрик или кэширования данных. Бот будет выполнять задачи обновления и чтения информации.

Для начала пойдем по пути ничего не подозревающего разработчика и попытаемся найти готовые решения.

Спойлер: написание ботов на c# почему-то не самое популярное и благоприятное направление.

Как опорную точку возьмем библиотеку Telegram.Bot, как саму популярную и развитую библиотеку для взаимодействия с API телеграм ботов. Зайдем на github и поищем кто и как ее использует.

Сюрприз, но c# почему-то не самый оптимальный выбор для написания телеграм ботов. Единственный серьезный проект, который я нашел, это какая-то странная игра для телеграма tgwerewolf github, но активно развивающаяся. Данный проект использует чистый Telegram.Bot, что-то для себя конечно можно вынести, но переносить будет тяжело.

C фрейморками дела обстоят чуть лучше. Я нашел аж 2 готовых решения.
Начнем с TelegramBotFramework — начали за здравие, а закончили за упокой.

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

На самом деле внедрение зависимостей есть, но оно костыльное и лучше даже палкой не тыкать, только если осторожно.

Ну и из минусов: тяжело было адаптировать в существующую экосистему asp.net; отсутствие внедрение зависимостей; тяжело кастомизировать под себя. В общем, точно не мой выбор, так что идем дальше.

И напоследок самое вкусное — TgBotFramework — небольшой, но интересный фреймворк от контрибьютера Telegram.Bot. Фреймворк хорошо написан, дружит с внедрением зависимостей, есть удобное описание команд для телеграм бота. Ну просто сказка, берем и идем писать телеграм бота.

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

Обидно, что c# и экосистема .NET никак не используются для написания телеграм ботов. Если глянуть в другие языки — python-telegram-bot и Telethon, go — telegram-bot-api и самая интересная реализация на rust— teloxide

Хотя может я плохо искал. Буду рад, если подскажете существуют ли еще какие-нибудь реализации фрейморков для телеграм бота на c#.

Ну, раз я такой притязательный к выбору, то не отчаиваемся — руки в ноги и делаем из костылей свое решение. Как вы могли уже догадаться, за основу будет взят медиатор. Самое главное при регистрации обработчиков команд объявить их как scoped.

Для начала напишем фоновую службу, которая будет запускать обработчик сообщений от бота:

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
{
}
}
}

Тут все просто. Крутим в фоне в цикле обработку сообщений. В ReceiveAsync, а точнее в DefaultUpdateReceiver, есть собственный цикл по отправке сообщений, но собственный while пригодится позже для восстановления после ошибок в нашем коде, как и сама обработка ошибок. Идем дальше к UpdateHandler.

Вот тут начинается вся магия!

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

Ничего удивительного нет: для каждого пользователя заводим scope и выбранную им команду. Дальше смотрим, есть ли какие-нибудь изменения: пользователь поменял команду; команда вернула NeedRefresh и так далее. Единственно, что может смутить — var scopeMediator = provider.ServiceProvider.GetRequiredKeyedService<IMediator>(nameof(CustomTelegramMediator)), но об этом позже.

Получение команды происходит через GetBotCommandNotification, не знаю, насколько это правильно, что notification возвращает результат, записывая его в GetBotCommandNotification. Сам GetBotCommandHandler очень простой:

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

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

Однако здесь возникает проблема: концептуально уведомление нужно для обработки множеством слушателей, а еще мы не можем применить IPipelineBehavior к уведомлениям.

Но на самом деле это не так. В Mediatr добавлена возможность создания своего медиатора и доступ к уведомлениям при их публикации. К тому же, недавно была добавлена возможность получения объекта по ключу (AddKeyed и его друзья), как все удачно сложилось. По-этому идем и пишем собственный медиатр для обработки сообщений.

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);
}
}
И да, это почти все, остался NotificationHandlerWrapperImpl для обработки уведомления и оборачивания ее в 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);
}
Теперь опишем контракт для уведомления об обработке комманды из телеграм бота

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();
}
А теперь если нам нужен IPipelineBehavior непосредственно для обработки комманды, то просто указываем where TRequest : IBotCommandContext. К примеру реализуем сохранение подробной информации о пользователях, которые пользуются ботом.

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();
}
}
А если надо подвязаться к существующей IPipelineBehavior, добавляем к реализации контракта IBotCommandContext маркер. К примеру есть следующий RequestPerformanceBehaviour с маркером 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;
Добавляем IRequestPerformance к реализации IBotCommandContext и все.

Дальше обработка комманд, для этого реализуем общий 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);
}
Дальше остается только клепать команды пока горячо.

Добавим простую команду:

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);
}
}
Добавим команду с несколькими этапами, эмитируем простой конечный автомат:

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);
}
}
Это только начало, дальше нас останавливают только фантазия и хотелки бизнеса. К примеру, можно накрутить TransactionBehavior на команды, которые посылаются из TelegramCommandHandler, так мы точно будем знать, что все прошло успешно и можно выдавать результат пользователю. Так же с кэшированием, метриками и так далее. Ну, конечно, если Ваша инфраструктура построена на медиаторе, хех.

В конце обработка ошибок. Единой точкой поимки ошибок является PollingServiceBase, так как внутри ReceiveAsync у себя при внутренних ошибках вызовет HandlePollingErrorAsync у IUpdateHandler. Так как IUpdateHandler располагает достаточной информацией, чтобы понять, у какого пользователя произошла ошибка, и ему же написать о ней.

Или можно можно обработчики комманд оборачивать в IPipelineBehavior для обработки ошибок, ведь теперь мы так умеем.

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

На этом все! Конечно, вышеописанная реализация только поверхностно покрывает обработку команд от телеграм бота. К примеру, неплохо бы прикрутить получение id сообщений, и удалять их, если пользователь выбрал другую команду.

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

Пример данной реализации можно найти на моем github.

 
Сверху