Вводная часть
Недавно передо мной встала задача написать тг бота для семестровой в универе, как выяснилось актуального материала на русском языке не то чтобы много, так что вот вам небольшая шпаргалка.
Кое-какие условия, чтобы бот был покруче
Бот должен работать через webhook'и
Необходима система команд, причем команды могут длиться дольше чем одно сообщение
Бот должен быть полностью асинхронным и выдерживать несколько пользователей одновременно
Создание бота и проекта
Первым делам нужно создать бота, тут все просто идём к @BotFather, выбираем имя боту и тд, и самое важное, получаем TOKEN, его сохраняем
Далее создадим проект в visual studio, в шаблонах выбираем Web-API ASP.NET Core, настройки примерно такие

Внутри проекта уже будет небольшой пример, удаляем, нам это ни к чему

Далее идем в файл Properties/launchSettings.json, его особо трогать не будем, только 2 url'а из 17 строки меняем на один из 7, именно по этому порту мы должны получать обновления от телеграма

Настройка webhook'ов
Что такое вообще эти ваши webhook'и? Есть два пути, как мы будем получать обновления от нашего бота (например сообщения от пользователя), либо мы будем раз в определенный промежуток времени спрашивать у него: "А что там нового?", либо мы настроим webhook'и и телеграм сам будет нам говорить, что бот получил какое-либо сообщение.
Но есть пару нюансов, чтобы получать апдейты от телеграма нужен SSL-сертификат, у меня такого нету, думаю, и у вас не завалялась парочка, поэтому будем использовать ngrok, бесплатный и очень полезный софт. Помните тот url в launchSettings? Естественно телеграм не сможет отправлять туда информацию, именно для этого нам нужен ngrok, запускаем и пишем команду ngrok http {наш url}, увидим мы следующее

Что это значит? У ngrok есть SSL-сертификат, следовательно, телеграм сможет отправлять апдейты как раз по этому адресу, а сам ngrok, будет уже передавать их нам на наш порт, магия, да и только.
Далее нам необходимо сказать телеграмму, от какого бота и куда отправлять апдейты, для этого необходимо использовать эту ссылку
https://api.telegram.org/bot{my_bot_token}/setWebhook?url={url_to_send_updates_to}
Вместо {my_bot_token} подставляем токен, который вам дал BotFather,
а в {url_to_send_updates_to} подставляем ссылку из ngrok
Начинаем писать код
Для работы с TelegramApi нам понадобится библиотека из NuGet: Telegram.Bot, кроме того, телеграм присылает нам данные в формате json, чтобы их парсить мы будем использовать Microsoft.AspNetCore.Mvc.NewtonsoftJson.
Первым делом мы создадим класс BotController, в котором и будем обрабатывать сообщения
using Microsoft.AspNetCore.Mvc;
using Telegram.Bot.Types;
namespace HabrPost.Controllers
{
[ApiController]
[Route("/")]
public class BotController : ControllerBase
{
[HttpPost]
public void Post(Update update) //Сюда будут приходить апдейты
{
Console.WriteLine(update.Message.Text);
}
[HttpGet]
public string Get()
{
//Здесь мы пишем, что будет видно если зайти на адрес,
//указаную в ngrok и launchSettings
return "Telegram bot was started";
}
}
}
Конечно телеграм сам не будет отправлять нам информацию классом Update(он использует JSON), именно для этого нам нужен Microsoft.AspNetCore.Mvc.NewtonsoftJson, идем в класс Program.cs и добавляем .AddNewtonsoftJson(); Теперь апдейты получаемые от телеги будут парсится в экземпляр класса Update, именно через него мы будем получать информацию.
namespace HabrPost
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers().AddNewtonsoftJson(); //<- вот сюда
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
}
}
И вот наш бот уже получает сообщения и пишет их в нашу консольку, теперь напишем самого бота, а точнее класс Bot
using Telegram.Bot;
namespace HabrPost
{
public class Bot
{
private static TelegramBotClient client { get; set; }
public static TelegramBotClient GetTelegramBot()
{
if (client != null)
{
return client;
}
client = new TelegramBotClient("Ваш токен");
return client;
}
}
}
Здесь вы можете видеть паттерн Singleton, простой, понятный, но при этом спорный и далеко не всем нравится, но пост не об этом. В нём мы просто создаём экземпляр TelegramBotClient, который и является нашим бот, будет отправлять сообщения и тд.
Теперь мы можем сделать так, чтобы наш бот приветствовал пользователей, вернемся в BotController
private TelegramBotClient bot = Bot.GetTelegramBot();
[HttpPost]
public async void Post(Update update)
{
long chatId = update.Message.Chat.Id; //получаем айди чата, куда нам сказать привет
await bot.SendTextMessageAsync(chatId, "Привет!");
}
Также мы сделали метод асинхронным, это необходимо, чтобы бот не зависал от большого количества пользователей. Далее весь код будет асинхронным.
И вот наш бот здоровается с создателем, неплохо, но пока не особо впечатляет. Далее мы создадим классы, чтобы каждый пользователь имел свой контекст выполнения команд, и саму систему команд.
UpdateDistributor
Начнем с того, что сделает так, чтобы наш бот мог отличать пользователей друг от друга, для этого напишем класс UpdateDistributor, как видно из названия он будет распределять апдейты, приходящие от телеги. Распределять он их будет по экземплярам класса T, здесь необязательно использовать дженерик, можно упросить и захардкодить единственный класс обработчик апдейтов. Как видите T должен реализовать интерфейс ITelegramUpdateListener, в нём единтсвенный метод GetUpdate, в который мы будем передавать апдейты. new() нам нужен, чтобы создавать экземпляры типа T
using Telegram.Bot.Types;
namespace HabrPost.Controllers
{
public class UpdateDistributor<T> where T : ITelegramUpdateListener, new()
{
private Dictionary<long, T> listeners;
public UpdateDistributor()
{
listeners = new Dictionary<long, T>();
}
public async Task GetUpdate(Update update)
{
long chatId = update.Message.Chat.Id;
T? listener = listeners.GetValueOrDefault(chatId);
if (listener is null)
{
listener = new T();
listeners.Add(chatId, listener);
await listener.GetUpdate(update);
return;
}
await listener.GetUpdate(update);
}
}
}
Разберем, что тут написано поподробнее, идентифицировать пользователей мы будем по их ChatId. Каждому сопоставим в словаре свой экземпляр T, таким образом каждый обработчик будет знать какому пользователю он соответствует и знать контекст выполнения команд.
CommandExecutor
Именно по экземплярам CommandExecutor мы будем раскидывать апдейты в UpdateDistributor. Кроме того, нам понадобятся команды, которые он будет выполнять, каждая команда, будет отдельным классом, реализующим интерфейс ICommand, его и напишем первым.
Команда должна уметь выполняться (метод Execute), иметь имя (свойство Name), и клиент через который она будет работать с ботом (свойство client)
using Telegram.Bot.Types;
using Telegram.Bot;
namespace HabrPost.Controllers.Commands
{
public interface ICommand
{
public TelegramBotClient Client { get; }
public string Name { get; }
public async Task Execute(Update update) { }
}
}
Таким образом, пример команды будет выглядеть примерно так
using Telegram.Bot;
using Telegram.Bot.Types;
namespace HabrPost.Controllers.Commands
{
public class StartCommand : ICommand
{
public TelegramBotClient Client => Bot.GetTelegramBot();
public string Name => "/start";
public async Task Execute(Update update)
{
long chatId = update.Message.Chat.Id;
await Client.SendTextMessageAsync(chatId, "Привет!");
}
}
}
Теперь напишем класс CommandExecutor, он должен иметь список команд, которые может выполнить, и метод в котором будет принимать апдейты (из интерфейса ITelegramUpdateListener).
using HabrPost.Controllers.Commands;
using Telegram.Bot.Types;
namespace HabrPost.Controllers
{
public class CommandExecutor : ITelegramUpdateListener
{
private List<ICommand> commands;
public CommandExecutor()
{
commands = new List<ICommand>();
{
new StartCommand();
}
}
public async Task GetUpdate(Update update)
{
Message msg = update.Message;
if(msg.Text == null) //такое бывает, во избежании ошибок делаем проверку
return;
foreach(var command in commands)
{
if(command.Name == msg.Text)
{
await command.Execute(update);
}
}
}
}
}
В конструкторе мы перечисляем команды, которые написали, в методе GetUpdate, пробегаемся по списку команду, если находим нужную выполняем ее.
Теперь пойдем в BotController и добавим туда наш UpdateDistributor, теперь он выглядит вот так
using Microsoft.AspNetCore.Mvc;
using Telegram.Bot;
using Telegram.Bot.Types;
namespace HabrPost.Controllers
{
[ApiController]
[Route("/")]
public class BotController : ControllerBase
{
private TelegramBotClient bot = Bot.GetTelegramBot();
private UpdateDistributor<CommandExecutor> updateDistributor = new UpdateDistributor<CommandExecutor>();
[HttpPost]
public async void Post(Update update)
{
if (update.Message == null) //и такое тоже бывает, делаем проверку
return;
await updateDistributor.GetUpdate(update);
}
[HttpGet]
public string Get()
{
return "Telegram bot was started";
}
}
}
И вот наш бот уже умеет выполнять односложные команды и отличать пользователей друг от друга
Многосложные команды
Однако бывает так, что необходимо создавать сложные команды, например регистрация, где пользователь сначала указывает номер, потом имя, фамилию и тд. В таком случае CommandExecutor иногда должен перенаправлять апдейты в команду, которая сейчас выполняется. Многосложные команды будут отличаться от прочих тем, что реализуют интерфейс IListener
using Telegram.Bot.Types;
namespace HabrPost.Controllers
{
public interface IListener
{
public async Task GetUpdate(Update update) { }
public CommandExecutor Executor { get; }
}
}
В какой-то момент надо прекратить получать апдейты от телеграмма, для этого храним CommandExecutor, чтобы в нужный момент сказать: "Всё больше не надо, ацтань". Соответственно меняем и CommandExecutor
using HabrPost.Controllers.Commands;
using Telegram.Bot.Types;
namespace HabrPost.Controllers
{
public class CommandExecutor : ITelegramUpdateListener
{
private List<ICommand> commands;
private IListener? listener = null;
public CommandExecutor()
{
commands = new List<ICommand>();
{
new StartCommand();
}
}
public async Task GetUpdate(Update update)
{
if (listener == null)
{
await ExecuteCommand(update);
}
else
{
await listener.GetUpdate(update);
}
}
private async Task ExecuteCommand(Update update)
{
Message msg = update.Message;
foreach (var command in commands)
{
if (command.Name == msg.Text)
{
await command.Execute(update);
}
}
}
public void StartListen(IListener newListener)
{
listener = newListener;
}
public void StopListen()
{
listener = null;
}
}
}
Добавляем поле listener, которое будет хранить команду, которой необходимо передавать апдейты. Также добавляем два метода, чтобы из таких команд в нужный момент сказать, теперь отправляй мне апдейты, а теперь перестань. В методе GetUpdate теперь мы проверяем есть ли слушатель, которому необходимо передавать апдейты, если есть ему просто и отдаем, в ином случае выполняем метод ExecuteCommand.
Теперь напишем пример такой команды, а именно регистрацию
using Telegram.Bot;
using Telegram.Bot.Types;
namespace HabrPost.Controllers.Commands
{
public class RegisterCommand : ICommand, IListener
{
public TelegramBotClient Client => Bot.GetTelegramBot();
public string Name => "Регистрация";
public CommandExecutor Executor { get; }
public RegisterCommand(CommandExecutor executor)
{
Executor = executor;
}
private string? phone = null;
private string? name = null;
public async Task Execute(Update update)
{
long chatId = update.Message.Chat.Id;
Executor.StartListen(this); //говорим, что теперь нам надо отправлять апдейты
await Client.SendTextMessageAsync(chatId, "Введите номер!");
}
public async Task GetUpdate(Update update)
{
long chatId = update.Message.Chat.Id;
if (update.Message.Text == null) //Проверочка
return;
if (phone == null) //Получаем номер, просим имя
{
phone = update.Message.Text;
await Client.SendTextMessageAsync(chatId, "Введите имя!");
}
else //Получаем имя, говорим, что больше нам апдейты не нужны
{
name = update.Message.Text;
await Client.SendTextMessageAsync(chatId, "Поздравляем с регистарцией!");
Executor.StopListen();
}
}
}
}
Теперь добавим в CommandExecutor нашу новую команду, также таким командам трtбуется указать CommandExecutor из которого он был создан.
public CommandExecutor()
{
commands = new List<ICommand>()
{
new StartCommand(),
new RegisterCommand(this)
};
}
Команды через рефлексию
Согласитесь, добавлять новые команды в конструкторе CommandExecutor не очень удобно, поэтому сделаем удобнее, с помощью рефлексии
private List<ICommand> GetCommands()
{
var types = AppDomain
.CurrentDomain
.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(ICommand).IsAssignableFrom(type))
.Where(type => type.IsClass);
List<ICommand> commands = new List<ICommand>();
foreach(var type in types)
{
ICommand? command;
if(typeof(IListener).IsAssignableFrom(type))
{
command = Activator.CreateInstance(type, this) as ICommand;
}
else
{
command = Activator.CreateInstance(type) as ICommand;
}
if(command != null)
{
commands.Add(command);
}
}
return commands;
}
public CommandExecutor()
{
Commands = GetCommands();
}
В методе GetCommands сначала мы выбираем все классы, которые реализуют ICommand, после перебираем все типы, которые мы получили, дальше необходимо создать экземпляры и добавить их в список, так как некоторым командам(реализующим IListener) в конструкторе необходимо указать CommandExecutor, то мы делаем соответствующую проверку, в зависимости от результата создаем экземпляр и добавляем в список.
Заключение
Собственно и все, надеюсь статья оказалось полезной, уж не знаю, как долго она будет актeальной, но общая логика для подобных ботов думаю пригодиться всегда.